Compare commits

...
Author SHA1 Message Date
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 Fable 5.1 6b3e73824c Merge main (PRs #5, #7, #9, #10) into the PCO Plan branch
Resolves the admin form conflicts in favour of this branch's grouped /
collapsible / dependent-select additions (the base hunks were the timers
lane's, ported verbatim), keeps both sides of the README and module-guide
edits, and adopts main's versioning rules: the shell moves to 1.3.0 for the
new schema features (configGroups / instanceGroups, help, color fields,
grouped and dependent selects) and pco-plan 1.3.0 declares
"proddash": ">=1.3.0".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:47:06 -04:00
Michael McGary 6d636190cd Merge pull request #7 from michaelmcgary2008/claude/prodash-propresenter-timers-cea871
Timers: LTC decoded from the audio signal via an external reader
2026-09-10 16:41:00 -04:00
Waters Church d2c087a256 Merge main (PRs #5, #10) into the timers branch 2026-09-10 16:39:58 -04:00
Michael McGary db6467132e Merge pull request #5 from michaelmcgary2008/claude/timers-ltc-stage-display
Timers: LTC timecode via the stage-display websocket
2026-09-10 16:39:35 -04:00
Waters ChurchandClaude Fable 5.1 d567f5459a pco-plan: edge-to-edge headers, tile colors for headers / current item / key, gradient switch
Section headers lose the bracket look (no accent bar, no rounded corners)
and span the full tile width, with text still aligned to the other rows.
A "Colors" group in the gear menu holds swatch pickers for the song key,
section headers and the current item, a switch for fading the header color
to the right (on by default), and the item-type tint. Colors apply as CSS
custom properties on the tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:31:46 -04:00
Waters ChurchandClaude Fable 5.1 a71b14d755 Shell: color field type (swatch picker) in tile settings and admin config
Renders <input type="color"> with the label beside it; the stored value is
always a #rrggbb string (3-digit hex widened, anything else falls back to
the schema default). Same styling in the gear popover and the admin form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:31:46 -04:00
Waters ChurchandClaude Fable 5.1 4c5d7956ab pco-plan: display settings are per tile again (gear menu), admin keeps the connection
Top bar (template, item-type tint, key color) and the whole show/hide
checklist move from admin config to instanceSchema, grouped in the gear
popover ("Top bar" open, "Show / hide" folded, three columns). They are
browser-side choices stored with the layout, not server state. Admin now
holds only credentials, service type, plan, poll interval and the
ProPresenter follow switch. noteCategories moves to the tile too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:20:46 -04:00
Waters ChurchandClaude Fable 5.1 01eac1bdc4 Shell: grouped, collapsible, multi-column per-tile settings popover
The gear popover learns the admin form's conventions: an instanceSchema
field's `group` starts a labeled subsection, `help` puts a line of guidance
under a control, and a manifest `instanceGroups[name]` adds
{ collapsed, columns, help } for that group (rendered as <details> with a
grid body; container queries drop to two or one column in narrow tiles).
/api/modules now exposes instanceGroups. Lets a module keep a long
show/hide checklist per tile — where display choices belong — without
the popover turning into a scroll of thirty loose checkboxes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:20:45 -04:00
Waters ChurchandClaude Fable 5.1 20dc2deb2b pco-plan: everything configured in Admin; key, section and top-bar restyle
- Drops the separate setup page: the service type is a select grouped by
  Planning Center folder path (GET /service-types), the pinned plan a
  select of upcoming/recent plans that follows the chosen service type
  (GET /plans?serviceTypeId=), credentials sit in a group that folds once
  saved.
- Show/hide moves from per-tile settings to a server-wide checklist in
  Admin (three columns, folded by default): top bar, series, part, service
  type, date, service time, total length, running clock, countdown,
  headers, item numbers, start times, length, type badge, key, arrangement,
  author/CCLI, description, notes, pre/post-service items, now indicator,
  item runtime, dim completed, ProPresenter line. Tiles keep only text
  size and auto-scroll.
- Top bar text is a template with placeholders ({series} • {part} |
  {serviceType} • {date} by default; • separates droppable segments, |
  starts the second line).
- Song key: larger, admin-chosen color (blue by default), always right
  after the title and before the type badge. Section headers are
  full-width accent bands. Optional background tint per item type.
- tools/pco-mock.js plan titles are part numbers, as in a real series.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:31:12 -04:00
Waters ChurchandClaude Fable 5.1 106ce0c1ce Admin: collapsible / multi-column config groups, help text, grouped and dependent selects
Ports the timers lane's admin schema additions unchanged (field `group`,
`showWhen`, type `switch`, `optionsRoute`) so the two branches merge
cleanly, and extends them for modules whose settings are richer than a
handful of fields:

- manifest `configGroups[name]` = { collapsed: true | false | "whenSet",
  columns: 2–4, help } — a credentials block that folds once it holds a
  saved secret, a show/hide checklist laid out as a compact grid
- `help` on any field: one line of guidance under the control
- select options may carry `group` → rendered as <optgroup>s (a folder
  tree browsable inside the select)
- `optionsDependsOn: "<key>"` refetches a select's options with that
  field's current value as a query parameter, now and on every change
- a save re-runs the card's dynamic option fetches, so a list that needed
  the just-saved credentials fills in without a page reload; a blank
  "— choose —" is no longer added when the module offers its own blank
  option

Documented in docs/MODULE-GUIDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:31:12 -04:00
Waters ChurchandClaude Opus 4.8 5fe39539fc Timers: admin "Timer font" option with a monospace choice
A new Display-group select (Default / Monospace) sets the font for the
timer and LTC digits. Monospace keeps the numbers from shifting sideways
as they change — easier to read on a fast-moving countdown or timecode.
Flows through server state → client applies a `tm-mono` class and re-fits
the digit sizing; both timer and LTC values (.tm-time) honor it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 15:23:07 -04:00
Waters ChurchandClaude Opus 4.8 1d46657e06 Timers: LTC is listener-only; admin switch, device/channel, grouped settings
Simplify LTC to its one working source — the built-in audio listener — and
make the admin UI reflect it.

Module:
- Remove the stage-display websocket source (stage-ws.js, stagePassword)
  and the /v1/timecode/status API probe — both dead on ProPresenter 21.4.
- Remove the remote-reader ingest (POST /ltc, ingestToken, tools/
  ltc-reader.js): the module owns LTC end to end now. The decoder selftest
  moves to modules/propresenter-timers/ltc.selftest.js so the engine stays
  covered.
- composeLtc collapses to the listener: running/stopped/nosignal, or a
  clear note when disabled / no device / capture failed / starting.
- Manifest: ltcEnabled is now a `switch`; ltcDevice and ltcChannel carry
  showWhen:"ltcEnabled"; ProPresenter and LTC fields carry `group` labels.

Shell (admin) — backward-compatible additions, coordinate with the shell lane:
- New `switch` field type (boolean shown as the enable-toggle control).
- `group`: consecutive fields render under a labeled subsection.
- `showWhen: "<key>"`: a field is shown only while that boolean/switch is
  on (and actually collapses — .field[hidden] override, since .field's
  display:flex outranks the [hidden] UA default).
- applyConfigPatch coerces `switch` as boolean. Documented in MODULE-GUIDE.

Verified: engine selftest (29 checks); listener decodes BlackHole loopback
(running 30fps, ageMs); device picker lists inputs; POST /ltc now 404s;
admin shows two labeled groups, the switch, and device/channel appearing
only when the switch is on (computed display none↔flex confirmed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 13:29:06 -04:00
Waters ChurchandClaude Opus 4.8 7e75146838 Timers: real-time frame-accurate LTC display
The LTC card repainted only on server anchors (~4x/s), so the frame
counter jumped in ~7-frame steps. Now the client counts every frame
locally between anchors and re-syncs on each one — frame-accurate and
smooth, with no extra network traffic.

- Server anchors carry ageMs (how old the decoded timecode is) so clients
  can pin the anchor to their own clock. ltc-listener reports it while
  running; composeLtc passes it through (listener) or derives it (reader).
- Client does exact SMPTE frame<->timecode math (parseTc/tcToFrames/
  framesToTc, incl. 29.97 drop-frame minute counting) and advances the
  display in a requestAnimationFrame loop at the LTC's own rate.
- Robust when rAF is throttled (backgrounded tab): render() paints the
  extrapolated frame at the 4x/s anchor rate too, so the display never
  freezes — the rAF loop only refines it to every frame when visible.
- Anchor jitter can't tick the counter backwards (small regressions
  clamped; genuine seeks/re-strikes pass); freshness cap stops freewheel
  into phantom frames when anchors stop arriving.

Verified: SMPTE math against the encoder over 200k drop-frame frames plus
round-trips; the extrapolation pipeline simulated at 30/25/29.97DF showing
strict +1-per-frame advance and correct DF boundary crossing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 13:12:52 -04:00
Waters ChurchandClaude Fable 5 dba10a30fe Merge main via the stage-display branch: data dir, installer, versioning
Adopts the new contract: the manifest declares "proddash": ">=1.2.0"
(this module's admin device picker needs the optionsRoute select support
this branch adds), module version 1.2.0, and the shell version bumps
1.1.0 → 1.2.0 for that admin capability. The optionsRoute paragraph
merged into the guide's new schema section cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 09:28:04 -04:00
Waters ChurchandClaude Fable 5 64502261ff Timers: version 1.1.0 (stage-display LTC), per the new versioning rules
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 09:26:50 -04:00
Waters Church 6427840223 Merge main: per-machine data dir, admin installer, module version requirements 2026-09-10 09:26:35 -04:00
Waters ChurchandClaude Fable 5.1 50caf02023 pco-plan: learn the shell's port from requests instead of config/proddash.json
Main now keeps a per-machine data directory whose proddash.json can override
the port, so reading the repo's config file was no longer a safe way to find
the ProPresenter module's loopback feed. Guess the port the way the shell
does (PORT, data-dir proddash.json, repo proddash.json, 24500), then confirm
it from the Host header of the first tile request; a learned port that
refuses the connection (a reverse proxy) is blacklisted and the guess kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:45:55 -04:00
Waters ChurchandClaude Fable 5.1 e7dc235a8a PCO Plan module: Planning Center order of service, following ProPresenter
New module modules/pco-plan: reads the current plan for a chosen Planning
Center Services service type (PAT or OAuth token), streams items with start
times, lengths, keys, descriptions and item notes to tiles (every detail a
per-tile show/hide setting), and follows the propresenter-now-next module's
live item over loopback SSE to highlight the current plan item and track
elapsed vs planned runtime. Ships its own setup page
(/modules/pco-plan/setup.html) to browse folders and pick the service type
through the shell admin API, plus tools/pco-mock.js for development.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:42:53 -04:00
Waters ChurchandClaude Fable 5 a68aed7443 Launcher: bundled runtime, and take the port over from a prior instance
Start ProdDash.command now prefers ./runtime/bin/node (production machines
have no system Node) and kills whatever already holds :24500 before
starting — so relaunching from the GUI (which the LTC listener needs for
microphone permission) is one double-click, not a port-conflict hunt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 17:22:59 -04:00
Waters ChurchandClaude Fable 5 b95aa7dc34 Timers: built-in LTC listener, configured in the admin page
The LTC listener now lives inside the module: it spawns the native
ltc-capture helper (moved into the module folder, compiled on first use,
exact-name device matching) for the admin-selected input device, extracts
the configured channel, and decodes SMPTE LTC in-process (ltc.js — the
engine, shared with tools/ltc-reader.js, which remains the remote-machine
option posting to /ltc).

Admin config: an "LTC listener" switch, an audio-device select populated
live from the new GET /ltc/devices route, and a 1-based channel number.
The switch off means LTC is gone entirely: no LTC entry in the Add-tile
picker, no LTC row in the Timers menu or All-timers grid, no stage/API
probes, and POST /ltc answers 409. Source precedence with it on:
listener-with-data, fresh remote reader, listener's own no-signal verdict,
then the legacy API/stage chain.

Shell (admin page): `select` fields learn `optionsRoute` — options fetched
from a module route at render, saved value kept selectable when the route
or device is gone. Documented in MODULE-GUIDE; backward compatible.
Coordinate with the shell lane before its next admin.js change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 17:21:48 -04:00
Waters ChurchandClaude Fable 5 c4db412014 ltc-reader: corrupt frames must not poison the frame rate
Real Dante capture produced a bit-error frame whose frames field read 35
yet passed the lax range check (f<=59, hours<=39), and max-frame-based
rate detection then reported 36 fps to the dashboard permanently. Now:
every BCD digit is validated and fields respect hard ceilings (f<=29,
h<=23); nominal fps locks only from two agreeing second-rollovers (the
frame number preceding f=0 with seconds+1), so one glitch can't fake it
and a genuine rate change re-locks within two seconds; the drop-frame
flag gets two-frame hysteresis. Selftest reproduces the forged-35 frame
plus PCM glitches and pins fps at 30 throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 11:28:37 -04:00
Waters ChurchandClaude Fable 5 c3d8483943 ltc-capture: native CoreAudio input capture, no ffmpeg needed
Production machines rarely have ffmpeg/sox, and the LTC reader only needs
"device N → s16le stdout". This ~200-line Swift CLI (compiles with the
Xcode CLT already on the target: swiftc -O -o tools/ltc-capture
tools/ltc-capture.swift) enumerates input devices (--list), pins an
AVAudioEngine tap to one picked by name substring (--device), and streams
interleaved s16le at the device's own rate/channel count, printing the
matching --sr/--channels reader flags to stderr.

Verified by a real loopback on BlackHole 64ch: synthesized LTC played into
the device, captured back (channel 2 of 64 @ 44.1 kHz), decoded to
running/stopped on live tiles with zero drift over 14 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 10:49:27 -04:00
Waters ChurchandClaude Fable 5 7ff8b42e90 ltc-reader: pick the LTC channel out of a multichannel capture
LTC usually rides one input of a console bus or Dante/MADI interface, and
downmixing it into program audio (-ac 1) corrupts the bit transitions. New
flags --channels <n> (interleaved count of the stdin stream, no practical
upper bound) and --ch <n> (1-based pick) deinterleave in the reader, so
the capture command stays a plain passthrough. The extractor keeps frame
alignment across arbitrary chunk splits; selftest adds an LTC-on-5-of-8
case fed in odd-sized chunks. Mono remains the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 16:06:53 -04:00
Waters ChurchandClaude Fable 5 cb77030eec Timers: LTC from the audio signal itself via an external reader
ProPresenter 21.4 exposes no timecode through any API, so read the LTC
where it actually lives — the audio feed:

- tools/ltc-reader.js: zero-dependency SMPTE LTC decoder (biphase-mark,
  adaptive bit clock, envelope-scaled comparator) fed s16le PCM on stdin
  from ffmpeg/sox; POSTs running/stopped/nosignal + timecode + frame rate
  (incl. 29.97 drop-frame) to the module. --selftest runs an encoder→
  decoder suite (24/25/30 fps, DF minute counting, low level + noise + DC,
  dropout recovery); --demo synthesizes live LTC through the real decoder
  and ingest path with no audio hardware.
- Module: POST /ltc ingest route (optional bearer token, validated body,
  6 s staleness with automatic fallback to the API probe / stage source),
  richer LTC state (status, fps, df, reader source), health messages.
- Client: LTC card shows Running · <rate> / Stopped (held frame) /
  No signal; works LTC-only with no ProPresenter host configured; the
  reader-fed card no longer grays out when only ProPresenter is down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:52:20 -04:00
Waters ChurchandClaude Fable 5 d5cb6b6cd9 Timers: heuristically bind LTC when the stage layout omits the field
Verified against ProPresenter 21.4: the legacy stage-display protocol
(ptl 610 — the only version PP21 accepts; newer values get 'out of
date') omits stage objects it predates, so a timecode field labeled LTC
never appears in any layout's frame list, and no timecode messages were
observed while idle. Fallback: an unknown uid streaming timecode-shaped
text (a fourth :FF group; not any configured timer's uuid) is bound as
LTC after two advancing samples, and that binding survives layout
refreshes. pp-timers-mock gains --ltc-unlisted to exercise the path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 17:05:23 -04:00
Waters ChurchandClaude Fable 5 444f03938d Timers: LTC timecode via the stage-display websocket
ProPresenter's HTTP API has no timecode route (verified: 21.4 returns
404), but its classic stage-display websocket still answers — so LTC now
comes from a stage layout field labeled "LTC": a zero-dependency RFC6455
client (stage-ws.js) authenticates (new stagePassword admin config),
resolves the LTC-labeled frame's uid from the layout list, and treats any
message carrying that uid as the timecode text. "Receiving" is a 2.5s
staleness heuristic; frozen or placeholder text reads as no-signal. The
HTTP probe stays authoritative when it ever answers (a 404 verdict is
retried once a minute), and the LTC card surfaces the reason when
timecode is unavailable (bad stage password, no LTC field). pp-timers-mock
grows a /stagedisplay websocket with --stage-pwd and --no-ltc-field to
exercise every path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 17:05:23 -04:00
47 changed files with 7512 additions and 139 deletions
+7
View File
@@ -7,3 +7,10 @@ dist/
# runtime state written by the admin page / layout saves
config/modules.json
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/
+9 -5
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
+59 -6
View File
@@ -29,6 +29,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,13 +88,16 @@ 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`) |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
@@ -179,16 +203,45 @@ below. Nothing is installed without someone clicking.
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"
and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config:
ProPresenter endpoint (host : port), password.
unmistakably red. Read-only — no start/stop controls. Admin config is in
three groups: **ProPresenter** (endpoint host : port, API password),
**LTC Timecode** (the listener switch, and — when it's on — the audio
device and channel), and **Display** (Timer font — Default or Monospace,
which keeps the digits from shifting as the numbers change).
ProPresenter exposes no timecode over any API, so LTC is decoded from the
audio signal, behind the admin **LTC listener** switch (off ⇒ no LTC tile
in the picker at all). On, the module captures the selected audio input
device/channel on the ProdDash machine — a native `ltc-capture` helper,
compiled on first use — and decodes the SMPTE frames in-process:
running/stopped/no-signal, the timecode counting frame-accurately in real
time, and the frame rate (incl. 29.97 drop-frame). macOS note: the
ProdDash server needs microphone permission — launch it from a logged-in
session (Start ProdDash.command) and approve the one-time prompt; a server
started over SSH or by bare launchd silently captures zeros. (Engine
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
- **PCO Plan** — the order of service from Planning Center Services: every
plan item with its scheduled start time, length, type, song key,
description and item notes. When the ProPresenter module is running it
follows that module's live item, highlights the matching plan item, dims
what's done and shows the elapsed vs planned runtime and how far
ahead/behind the service is. Admin config is the connection: Planning
Center credentials (Personal Access Token app ID + secret, or an OAuth
token), the service type (a list grouped by your Planning Center folders)
and an optional pinned plan. Everything about the display is per tile
(gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
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`) and `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`). See the testing section of the
`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`
(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).
## Install as a desktop web app
Regular → Executable
+7
View File
@@ -1,4 +1,11 @@
#!/bin/zsh
# Double-click to start ProdDash (macOS)
cd "$(dirname "$0")"
# One dashboard per machine: take the port over from any prior instance
# (an SSH- or launchd-started one has no microphone access for the LTC
# listener — launching from here fixes that).
OLD=$(lsof -t -iTCP:24500 -sTCP:LISTEN 2>/dev/null)
[[ -n "$OLD" ]] && kill $OLD 2>/dev/null && sleep 1
# Prefer the bundled runtime (production machines have no system Node)
if [[ -x ./runtime/bin/node ]]; then exec ./runtime/bin/node server.js; fi
exec node server.js
+112 -3
View File
@@ -66,6 +66,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,6 +91,7 @@ the app); its settings are kept for a later reinstall.
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
| `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. |
| `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. |
@@ -99,13 +103,75 @@ the app); its settings are kept for a later reinstall.
Both schemas map field names to specs:
```
{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
"label": "Shown next to the field",
"default": <value>,
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
"group": "Section heading", // optional; see below
"showWhen": "<other boolean/switch key>", // optional; see below
"help": "One line shown under the control", // optional, admin config only
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
"optionsRoute": "/devices", // "select" only, admin config only
"optionsDependsOn": "<other key>" // with optionsRoute; see below
}
```
`boolean` 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).
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
default; the tile applies it as a CSS custom property — see `pco-plan`).
`group` gathers consecutive fields under a labeled subsection in the admin
page, so a module with several distinct concerns (e.g. a ProPresenter
connection and an LTC audio input) reads as clearly separated blocks. Fields
with no `group` render loose, before/after the grouped ones in schema order.
A manifest-level `configGroups` map can dress a group up further, keyed by
the group name:
```json
"configGroups": {
"Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
"Show / hide": { "collapsed": true, "columns": 3 }
}
```
- `collapsed`: `true` (starts folded), `false` (starts open but can fold),
or `"whenSet"` — folded once the group already holds values (a saved
password, or anything other than the schema default), open while it is
still empty. Omit it and the group is a plain, always-open block.
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
long checklist of booleans compact.
- `help`: a line of guidance at the top of the group.
`help` on a field puts one line of guidance under its control — allowed
placeholders, where a value comes from. Keep labels short and put the
explanation there.
`group` and `help` work on `instanceSchema` fields too, and a manifest
`instanceGroups` map plays the `configGroups` role for the tile's gear
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
Display choices belong there, per tile; admin config is for connections.
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
`<key>` is on — e.g. the audio device and channel appear only once the LTC
listener switch is enabled. The hidden field still keeps its stored value.
A `select` whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
refresh of — static `options`: the admin page GETs
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
"label" }, … ] }` from one of the module's own server routes. The saved value
stays selectable even when the route is down or the option has vanished. (See
`propresenter-timers`' "Audio device" for a worked example.)
An option may carry a `group`: options sharing one render under a single
`<optgroup>`, so a tree (Planning Center folders, device classes) is
browsable inside the select. `optionsDependsOn: "<key>"` makes the admin
page fetch the options with that other field's current value appended as a
query parameter (`?<key>=<value>`), now and again whenever it changes — e.g.
a plan list that follows the chosen service type. (Both in `pco-plan`.)
`password` fields are admin-config only in practice: their values are stored
in the server's `modules.json` (in the per-machine data directory, see the
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
@@ -145,6 +211,38 @@ Admin config lives in `modules.json` inside the server's data directory
survives updates of the app folder). Per-tile settings live inside each
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
@@ -496,7 +594,18 @@ client-side failures (a tile that fails to start says so in the tile).
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
overrunning, stopped and elapsed timers plus an LTC timecode that
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
a timecode API). Also serves the stage-display websocket with an
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
- Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service
types, plans (last week / today / next week), plan times and items with
songs, keys and item notes, behind a Basic/Bearer auth check. Planning
Center is a cloud API, so the module reads its base URL from the
environment instead of admin config: run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
Application ID + Secret under PCO Plan in `/admin`, save, then pick
"Sunday Service" from the Service type list. Its item titles match
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
**The resilience checklist** — every module must pass this before it's done:
+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()
}
}
+322
View File
@@ -0,0 +1,322 @@
import Foundation
/**
Where things are, and which settings belong to whom.
Two kinds of setting live in this app, and they are deliberately kept apart:
* **ProdDash settings** (the port) belong to ProdDash, so they are written
where ProdDash already looks for them — `proddash.json` in the per-machine
data directory. Start the server any other way (`Start ProdDash.command`,
a terminal) and it still comes up on the port set here.
* **Launcher settings** (auto-start, restart-on-crash, where the checkout
lives) belong to this app and live in its own preferences.
The data-directory and port rules mirror server.js exactly — if that file's
`resolveDataDir` / `PORT` logic changes, change it here too.
*/
enum ProdDash {
// MARK: - Data directory (mirrors server.js resolveDataDir)
/// `PRODDASH_DATA_DIR` if the environment sets one (relative paths resolve
/// against the checkout, as in server.js), else the per-machine default.
static func dataDir(root: URL?) -> URL {
let fromEnv = (ProcessInfo.processInfo.environment["PRODDASH_DATA_DIR"] ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !fromEnv.isEmpty {
if fromEnv.hasPrefix("/") { return URL(fileURLWithPath: fromEnv) }
if let root { return root.appendingPathComponent(fromEnv).standardizedFileURL }
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/ProdDash")
}
// MARK: - What the app carries
/**
A shipped ProdDash lives read-only inside the app bundle and runs from a
copy in the data directory.
The copy isn't ceremony: ProdDash updates itself in place — the admin
page's "Update now" writes a fresh download over the app folder — and
nothing may write inside a signed .app without breaking its signature.
So the bundle carries the baseline, the data directory carries the copy
that actually runs, and both routes to a new version keep working: drop
in a newer .app, or press Update now.
A development build ships no payload and falls through to the checkout it
was built in.
*/
static func bundledPayload() -> URL? {
guard let resources = Bundle.main.resourceURL else { return nil }
let payload = resources.appendingPathComponent("app")
return isRoot(payload) ? payload : nil
}
/// The Node the app carries, so nothing depends on what this Mac happens
/// to have installed — or on it staying installed.
static func bundledNode() -> URL? {
guard let resources = Bundle.main.resourceURL else { return nil }
let node = resources.appendingPathComponent("node/bin/node")
return FileManager.default.isExecutableFile(atPath: node.path) ? node : nil
}
static func workingCopy(dataDir: URL) -> URL { dataDir.appendingPathComponent("app") }
/**
Put the bundled ProdDash in place: when there is no copy yet, or when the
app carries a newer one than the copy — someone installed a new .app. A
copy that has updated itself *past* the bundle is left alone, so a new
install never drags a running system backwards.
*/
@discardableResult
static func seedWorkingCopy(dataDir: URL, log: (String) -> Void) -> URL? {
guard let payload = bundledPayload() else { return nil }
let working = workingCopy(dataDir: dataDir)
let shipped = shellVersion(root: payload)
if isRoot(working) {
let installed = shellVersion(root: working)
guard compareVersions(shipped, installed) > 0 else { return working }
log("this app carries ProdDash \(shipped) — replacing the \(installed) copy it runs")
} else {
log("first run — installing ProdDash \(shipped)")
}
let fm = FileManager.default
let previous = working.appendingPathExtension("previous")
do {
try fm.createDirectory(at: dataDir, withIntermediateDirectories: true)
try? fm.removeItem(at: previous)
if fm.fileExists(atPath: working.path) { try fm.moveItem(at: working, to: previous) }
try fm.copyItem(at: payload, to: working)
makeWritable(working)
try? fm.removeItem(at: previous)
return working
} catch {
log("could not install the bundled ProdDash: \(error.localizedDescription)")
// Put back whatever was working before rather than leaving nothing.
if !fm.fileExists(atPath: working.path), fm.fileExists(atPath: previous.path) {
try? fm.moveItem(at: previous, to: working)
}
return isRoot(working) ? working : nil
}
}
/// Files copied out of a bundle can arrive read-only, and this copy is
/// exactly where ProdDash writes its own updates.
private static func makeWritable(_ root: URL) {
let fm = FileManager.default
var targets = [root]
if let walker = fm.enumerator(at: root, includingPropertiesForKeys: nil) {
for case let url as URL in walker { targets.append(url) }
}
for url in targets {
guard let mode = (try? fm.attributesOfItem(atPath: url.path))?[.posixPermissions] as? NSNumber
else { continue }
let writable = mode.uint16Value | 0o200
if writable != mode.uint16Value {
try? fm.setAttributes([.posixPermissions: NSNumber(value: writable)], ofItemAtPath: url.path)
}
}
}
// MARK: - Finding the checkout
/// A folder is ProdDash if it has the two files the server cannot run without.
static func isRoot(_ url: URL) -> Bool {
let fm = FileManager.default
return fm.fileExists(atPath: url.appendingPathComponent("server.js").path)
&& fm.fileExists(atPath: url.appendingPathComponent("public/index.html").path)
}
/**
Find the ProdDash folder: the one the operator chose, else the checkout
this .app was built inside (the app usually sits in `launcher/macos/build`),
else the handful of places people actually keep it.
*/
static func findRoot(preferred: String?, dataDir: URL) -> URL? {
if let preferred, !preferred.isEmpty {
let url = URL(fileURLWithPath: preferred).standardizedFileURL
if isRoot(url) { return url }
}
if let env = ProcessInfo.processInfo.environment["PRODDASH_ROOT"], !env.isEmpty {
let url = URL(fileURLWithPath: env).standardizedFileURL
if isRoot(url) { return url }
}
// A shipped app runs the copy it installed; only a development build
// (no payload) goes looking for a checkout.
if bundledPayload() != nil {
let working = workingCopy(dataDir: dataDir)
if isRoot(working) { return working }
}
var up = Bundle.main.bundleURL.standardizedFileURL
for _ in 0..<6 {
up.deleteLastPathComponent()
if up.path == "/" { break }
if isRoot(up) { return up }
}
let home = FileManager.default.homeDirectoryForCurrentUser
var candidates = [
home.appendingPathComponent("Apps/ProdDash"),
home.appendingPathComponent("ProdDash"),
home.appendingPathComponent("Documents/ProdDash"),
URL(fileURLWithPath: "/Applications/ProdDash"),
URL(fileURLWithPath: "/Users/Shared/ProdDash"),
]
// …and next to the app itself, for a "ProdDash Launcher.app beside the folder" layout.
candidates.append(Bundle.main.bundleURL.deletingLastPathComponent()
.appendingPathComponent("ProdDash"))
return candidates.first(where: isRoot)?.standardizedFileURL
}
/// The shell's version, straight from the checkout's package.json.
static func shellVersion(root: URL) -> String {
guard let data = try? Data(contentsOf: root.appendingPathComponent("package.json")),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let version = json["version"] as? String else { return "" }
return version
}
// MARK: - Finding node
/// Everywhere a Mac plausibly keeps node, in the order worth trying.
static func findNode(root: URL?, preferred: String?) -> URL? {
var tries: [URL] = []
if let preferred, !preferred.isEmpty { tries.append(URL(fileURLWithPath: preferred)) }
// What the app carries, so a shipped ProdDash needs nothing installed.
if let bundled = bundledNode() { tries.append(bundled) }
// The runtime a production machine ships with — same preference as Start ProdDash.command.
if let root { tries.append(root.appendingPathComponent("runtime/bin/node")) }
tries += ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/opt/local/bin/node", "/usr/bin/node"]
.map { URL(fileURLWithPath: $0) }
let home = FileManager.default.homeDirectoryForCurrentUser
tries += newestNodeUnder(home.appendingPathComponent(".nvm/versions/node"))
tries += [home.appendingPathComponent(".volta/bin/node")]
if let found = tries.first(where: isExecutable) { return found }
// Last resort: ask a login shell, which is where a fancier version
// manager (asdf, fnm, mise) puts node on the PATH.
if let fromShell = loginShellNode(), isExecutable(fromShell) { return fromShell }
return nil
}
private static func isExecutable(_ url: URL) -> Bool {
FileManager.default.isExecutableFile(atPath: url.path)
}
private static func newestNodeUnder(_ dir: URL) -> [URL] {
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return [] }
return names.sorted { compareVersions($0, $1) > 0 }
.map { dir.appendingPathComponent($0).appendingPathComponent("bin/node") }
}
private static func loginShellNode() -> URL? {
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
let task = Process()
task.executableURL = URL(fileURLWithPath: shell)
task.arguments = ["-lc", "command -v node"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
do { try task.run() } catch { return nil }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
let path = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return path.hasPrefix("/") ? URL(fileURLWithPath: path) : nil
}
/// `node -v`, so the window can say which runtime is about to be used.
static func nodeVersion(_ node: URL) -> String {
let task = Process()
task.executableURL = node
task.arguments = ["-v"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
do { try task.run() } catch { return "" }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
/// node 18 is the floor the server's package.json declares.
static func nodeIsTooOld(_ version: String) -> Bool {
let major = Int(version.dropFirst(version.hasPrefix("v") ? 1 : 0)
.prefix(while: { $0.isNumber })) ?? 0
return major > 0 && major < 18
}
// MARK: - The port (mirrors server.js: PORT env, then data dir, then checkout, then 24500)
static let defaultPort = 24500
static func port(root: URL?, dataDir: URL) -> Int {
if let fromFile = jsonNumber(at: dataDir.appendingPathComponent("proddash.json"), key: "port") {
return fromFile
}
if let root, let fromRepo = jsonNumber(at: root.appendingPathComponent("config/proddash.json"), key: "port") {
return fromRepo
}
return defaultPort
}
/**
Write the port into the data directory's proddash.json, preserving every
other key (the admin passcode lives in the same file). The launcher also
passes PORT to the server it starts, so this write is what keeps a start
by any other means on the same port.
*/
static func setPort(_ port: Int, dataDir: URL) throws {
let file = dataDir.appendingPathComponent("proddash.json")
var object: [String: Any] = [:]
if let data = try? Data(contentsOf: file),
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
object = existing
}
object["port"] = port
try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
// Same atomic-ish write the server uses: never leave a half-written config.
let tmp = file.appendingPathExtension("tmp")
try Data((inHouseStyle(data) + "\n").utf8).write(to: tmp)
_ = try FileManager.default.replaceItemAt(file, withItemAt: tmp)
}
/// Foundation writes `"port" : 24500`; the server writes `"port": 24500`.
/// This file is read and hand-edited by people — keep one house style.
private static func inHouseStyle(_ json: Data) -> String {
(String(data: json, encoding: .utf8) ?? "")
.split(separator: "\n", omittingEmptySubsequences: false)
.map { line in
guard line.trimmingCharacters(in: .whitespaces).hasPrefix("\""),
let colon = line.range(of: "\" : ") else { return String(line) }
return line.replacingCharacters(in: colon, with: "\": ")
}
.joined(separator: "\n")
}
static func isValidPort(_ port: Int) -> Bool { port >= 1 && port <= 65535 }
private static func jsonNumber(at url: URL, key: String) -> Int? {
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let value = json[key] as? NSNumber else { return nil }
let port = value.intValue
return isValidPort(port) ? port : nil
}
// MARK: - Misc
/// "1.10.0" > "1.9.0" — the numeric comparison version strings deserve.
static func compareVersions(_ a: String, _ b: String) -> Int {
let lhs = a.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
let rhs = b.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
for i in 0..<max(lhs.count, rhs.count) {
let l = i < lhs.count ? lhs[i] : 0
let r = i < rhs.count ? rhs[i] : 0
if l != r { return l < r ? -1 : 1 }
}
return 0
}
}
@@ -0,0 +1,45 @@
import Foundation
/// The launcher's own preferences — everything that is about *running* ProdDash
/// rather than about ProdDash itself. (The port is not here: it belongs to
/// ProdDash and is written to its proddash.json — see LauncherConfig.)
final class LauncherSettings: ObservableObject {
private let defaults = UserDefaults.standard
@Published var rootPath: String { didSet { defaults.set(rootPath, forKey: "rootPath") } }
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
/// Start the server as soon as the launcher opens (so a login item brings the
/// whole dashboard up by itself).
@Published var startServerAtLaunch: Bool { didSet { defaults.set(startServerAtLaunch, forKey: "startServerAtLaunch") } }
/// Bring the server back if it dies on its own. Backs off, and gives up
/// after a burst of fast crashes rather than looping forever.
@Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } }
/// Open the dashboard in a browser whenever the server comes up.
@Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } }
/// Show this window at launch. Off means the launcher starts straight into
/// the menu bar (Companion's "start minimised").
@Published var showWindowAtLaunch: Bool { didSet { defaults.set(showWindowAtLaunch, forKey: "showWindowAtLaunch") } }
init() {
defaults.register(defaults: [
"startServerAtLaunch": true,
"restartOnCrash": true,
"openDashboardOnStart": false,
"showWindowAtLaunch": true,
])
rootPath = defaults.string(forKey: "rootPath") ?? ""
nodePath = defaults.string(forKey: "nodePath") ?? ""
startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch")
restartOnCrash = defaults.bool(forKey: "restartOnCrash")
openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart")
showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch")
}
/// False until the launcher has run once — used to open the window (and ask
/// for the permissions ProdDash needs) the first time only.
var hasRunBefore: Bool {
get { defaults.bool(forKey: "hasRunBefore") }
set { defaults.set(newValue, forKey: "hasRunBefore") }
}
}
+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,428 @@
import AppKit
import Foundation
enum ServerState: Equatable {
case stopped
case starting
case running
case stopping
case failed(String)
var isBusy: Bool { self == .starting || self == .running || self == .stopping }
var label: String {
switch self {
case .stopped: return "Stopped"
case .starting: return "Starting…"
case .running: return "Running"
case .stopping: return "Stopping…"
case .failed: return "Problem"
}
}
}
/**
Starts, stops and watches over the ProdDash server.
The shell was built for this: it takes `PRODDASH_LAUNCHER=1` to mean "someone
is supervising me", and then exits **75** instead of respawning itself when
the admin page applies an update — so a 75 here is a restart request, not a
fault. Anything else unexpected is a crash: it gets a backed-off restart, and
a burst of fast crashes stops the cycle rather than looping forever.
Starting the server from this app is also what gives the modules their
permissions. macOS hands a child process the *responsible process* of its
parent, so node — and the `ltc-capture` tool the Timers module runs — inherit
this app bundle's identity, and the microphone and local-network prompts are
asked, and remembered, in ProdDash's name. A server started over SSH or from
a bare launchd job has no such identity, which is why its LTC input is
silently all zeros.
*/
final class ServerController: ObservableObject {
@Published private(set) var state: ServerState = .stopped
/// One line under the status pill: why it stopped, what to fix.
@Published private(set) var message = ""
@Published private(set) var port: Int = ProdDash.defaultPort
@Published private(set) var shellVersion = ""
@Published private(set) var localURL = ""
@Published private(set) var networkURLs: [String] = []
@Published private(set) var settingsDir = ""
/// The server answered /api/modules — listening, not merely alive.
@Published private(set) var healthy = false
@Published private(set) var rootURL: URL?
@Published private(set) var nodeURL: URL?
@Published private(set) var nodeVersion = ""
/// Asked when the port is already taken: (port, looks like ProdDash, reply).
var onPortBusy: ((Int, Bool, @escaping (Bool) -> Void) -> Void)?
private let settings: LauncherSettings
private let log = LogStore.shared
private var child: ChildProcess?
private var intentionalStop = false
private var restartAfterExit = false
private var startedAt: Date?
private var crashTimes: [Date] = []
private var pendingRestart: DispatchWorkItem?
private var healthTimer: Timer?
private var healthMisses = 0
private var openedDashboardThisRun = false
/// The most recent line that reads like a reason, shown with a failure.
private var lastProblemLine = ""
init(settings: LauncherSettings) {
self.settings = settings
refreshEnvironment()
}
// MARK: - Environment
/// Re-resolve the checkout, the node binary and the port. Cheap, and run
/// again before every start so a folder fixed in Settings takes effect.
func refreshEnvironment() {
// A shipped app installs (or re-installs) its copy of ProdDash before
// going looking for one. An explicitly chosen folder skips all that.
let base = ProdDash.dataDir(root: nil)
if settings.rootPath.isEmpty {
ProdDash.seedWorkingCopy(dataDir: base) { [log] message in log.launcher(message) }
}
let root = ProdDash.findRoot(preferred: settings.rootPath, dataDir: base)
rootURL = root
let node = ProdDash.findNode(root: root, preferred: settings.nodePath)
if node?.path != nodeURL?.path {
nodeURL = node
nodeVersion = node.map(ProdDash.nodeVersion) ?? ""
}
if let root {
let version = ProdDash.shellVersion(root: root)
if !version.isEmpty { shellVersion = version }
}
if !state.isBusy {
port = ProdDash.port(root: root, dataDir: dataDir)
}
}
var dataDir: URL { ProdDash.dataDir(root: rootURL) }
var dashboardURL: URL { URL(string: "http://localhost:\(port)/")! }
var adminURL: URL { URL(string: "http://localhost:\(port)/admin")! }
// MARK: - Start
func start() {
guard !state.isBusy else { return }
pendingRestart?.cancel()
pendingRestart = nil
refreshEnvironment()
guard let root = rootURL else {
return fail("Can't find the ProdDash folder. Choose it under Settings.")
}
guard let node = nodeURL else {
return fail("Node.js 18 or newer isn't installed. Install it from nodejs.org, or set the path under Settings.")
}
if ProdDash.nodeIsTooOld(nodeVersion) {
return fail("This machine's node is \(nodeVersion) — ProdDash needs 18 or newer.")
}
state = .starting
message = ""
localURL = ""
networkURLs = []
healthy = false
healthMisses = 0
openedDashboardThisRun = false
lastProblemLine = ""
let port = self.port
// The port probe and the "is that ProdDash?" request both block; keep
// them off the main queue so the window stays live.
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let busy = Net.probeSync(host: "127.0.0.1", port: UInt16(port), timeout: 1.0) == .open
let isProdDash = busy && Self.respondsAsProdDash(port: port)
DispatchQueue.main.async {
guard let self, self.state == .starting else { return }
guard busy else { return self.spawn(root: root, node: node, port: port) }
guard let ask = self.onPortBusy else {
return self.fail("Port \(port) is already in use.")
}
ask(port, isProdDash) { takeOver in
guard takeOver else {
self.state = .stopped
self.message = "Port \(port) is already in use."
return
}
self.takeOverPort(port)
self.spawn(root: root, node: node, port: port)
}
}
}
}
private func spawn(root: URL, node: URL, port: Int) {
var env = ProcessInfo.processInfo.environment
env["PRODDASH_LAUNCHER"] = "1" // "someone is supervising me" — see the note above
env["PORT"] = String(port)
// A GUI app inherits almost no PATH. Put node's own folder first, and
// keep the usual system ones so the Timers module can still find swiftc
// when it needs to build its capture tool.
let base = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
env["PATH"] = node.deletingLastPathComponent().path + ":" + base + ":/usr/local/bin:/opt/homebrew/bin"
log.launcher("starting: \(node.path) server.js (in \(root.path), port \(port))")
do {
child = try ChildProcess.run(
executable: node.path,
arguments: ["server.js"],
directory: root.path,
environment: env,
onLine: { [weak self] line, isError in self?.handleOutput(line, isError: isError) },
onExit: { [weak self] code, bySignal in self?.handleExit(code: code, bySignal: bySignal) })
intentionalStop = false
startedAt = Date()
startHealthPolling()
} catch {
fail(error.localizedDescription)
}
}
/// Hand the port over from a ProdDash somebody started another way. The
/// server we are about to start waits for the port to free up on its own.
private func takeOverPort(_ port: Int) {
let pids = Net.listenerPIDs(port: port)
guard !pids.isEmpty else { return }
log.launcher("taking port \(port) over from pid \(pids.map(String.init).joined(separator: ", "))")
for pid in pids { kill(pid, SIGTERM) }
Thread.sleep(forTimeInterval: 0.6)
}
// MARK: - Stop / restart
func stop() {
pendingRestart?.cancel()
pendingRestart = nil
guard let child else {
state = .stopped
return
}
intentionalStop = true
state = .stopping
message = ""
log.launcher("stopping ProdDash")
child.terminate()
let pid = child.pid
// The server closes in about a second and a half; this is the backstop.
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, let current = self.child, current.pid == pid, current.isAlive else { return }
self.log.launcher("ProdDash didn't stop on its own after 8s — forcing it")
current.forceKill()
}
}
func restart() {
guard child != nil else { return start() }
restartAfterExit = true
stop()
}
func toggle() {
switch state {
case .running, .starting: stop()
case .stopped, .failed: start()
case .stopping: break
}
}
private func scheduleRestart(after seconds: TimeInterval) {
let work = DispatchWorkItem { [weak self] in self?.start() }
pendingRestart = work
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
}
private func fail(_ reason: String) {
state = .failed(reason)
message = reason
log.launcher(reason)
}
// MARK: - Child output
private func handleOutput(_ line: String, isError: Bool) {
log.append(line, kind: isError ? .err : .out)
if line.contains("cannot start") || line.contains("Error:") || line.contains("EADDRINUSE") {
lastProblemLine = line.trimmingCharacters(in: .whitespaces)
}
parseBanner(line)
}
/**
The server prints a short banner once it is listening:
ProdDash 1.3.0
Local : http://localhost:24500
Network : http://10.0.1.42:24500
Modules : clock, prodcom-transcript, …
Settings : /Users/booth/Library/Application Support/ProdDash
Reading it is how the launcher learns it is really up (before the health
poll gets there) and what to show in the window.
*/
private func parseBanner(_ line: String) {
let text = line.trimmingCharacters(in: .whitespaces)
if text.hasPrefix("ProdDash "), text.dropFirst(9).first?.isNumber == true {
shellVersion = String(text.dropFirst(9)).trimmingCharacters(in: .whitespaces)
if state == .starting {
state = .running
message = ""
log.launcher("ProdDash \(shellVersion) is up on port \(port)")
if settings.openDashboardOnStart { openDashboard() }
openedDashboardThisRun = true
}
return
}
guard let colon = text.firstIndex(of: ":") else { return }
let field = text[text.startIndex..<colon].trimmingCharacters(in: .whitespaces)
let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)
switch field {
case "Local": localURL = value
case "Network": if !networkURLs.contains(value) { networkURLs.append(value) }
case "Settings": settingsDir = value
default: break
}
}
// MARK: - Exit
private func handleExit(code: Int32, bySignal: Bool) {
stopHealthPolling()
child = nil
healthy = false
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
if restartAfterExit {
restartAfterExit = false
state = .stopped
start()
return
}
if intentionalStop {
state = .stopped
message = "Stopped."
log.launcher("ProdDash stopped")
return
}
// 75 is the shell's "start me again" — the admin page applied an update.
if !bySignal && code == 75 {
log.launcher("ProdDash applied an update and asked to be restarted")
state = .stopped
message = "Restarting to apply an update…"
start()
return
}
let how = bySignal ? "was killed by signal \(code)" : "exited with code \(code)"
let detail = lastProblemLine.isEmpty ? "" : " — \(lastProblemLine)"
crashTimes.append(Date())
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
guard settings.restartOnCrash, crashTimes.count < 4 else {
let why = settings.restartOnCrash
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
: "ProdDash \(how)."
let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)"
fail(why + last)
Notifier.shared.post(title: "ProdDash stopped", body: why)
return
}
let delay = [1.0, 2.0, 5.0, 10.0][min(crashTimes.count - 1, 3)]
state = .stopped
message = "ProdDash \(how) — restarting…"
log.launcher("ProdDash \(how)\(detail); restarting in \(Int(delay))s")
Notifier.shared.post(title: "ProdDash stopped", body: "It \(how)\(detail). Restarting…")
scheduleRestart(after: delay)
}
// MARK: - Health
private func startHealthPolling() {
healthTimer?.invalidate()
let timer = Timer(timeInterval: 3, repeats: true) { [weak self] _ in self?.pollHealth() }
RunLoop.main.add(timer, forMode: .common)
healthTimer = timer
}
private func stopHealthPolling() {
healthTimer?.invalidate()
healthTimer = nil
}
private func pollHealth() {
let port = self.port
DispatchQueue.global(qos: .utility).async { [weak self] in
let ok = Self.respondsAsProdDash(port: port)
DispatchQueue.main.async {
guard let self, self.child != nil else { return }
if ok {
self.healthMisses = 0
self.healthy = true
if self.state == .starting {
self.state = .running
if self.settings.openDashboardOnStart, !self.openedDashboardThisRun {
self.openedDashboardThisRun = true
self.openDashboard()
}
}
} else {
self.healthMisses += 1
if self.healthMisses >= 2 { self.healthy = false }
}
}
}
}
/// Does something on this port answer ProdDash's own unauthenticated
/// module list? Blocking — background queues only.
static func respondsAsProdDash(port: Int) -> Bool {
guard let url = URL(string: "http://127.0.0.1:\(port)/api/modules") else { return false }
var request = URLRequest(url: url)
request.timeoutInterval = 2
request.httpMethod = "GET"
let semaphore = DispatchSemaphore(value: 0)
var ok = false
URLSession.shared.dataTask(with: request) { data, response, _ in
defer { semaphore.signal() }
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
let data, let body = String(data: data, encoding: .utf8) else { return }
ok = body.contains("\"modules\"")
}.resume()
_ = semaphore.wait(timeout: .now() + 3)
return ok
}
// MARK: - Settings ProdDash owns
/// Write the port where ProdDash reads it, then restart if it is running.
func applyPort(_ newPort: Int) {
guard ProdDash.isValidPort(newPort) else {
message = "\(newPort) isn't a usable port number."
return
}
guard newPort != port else { return }
do {
try ProdDash.setPort(newPort, dataDir: dataDir)
port = newPort
log.launcher("port set to \(newPort)")
if child != nil { restart() }
} catch {
message = "Couldn't save the port: \(error.localizedDescription)"
log.launcher(message)
}
}
// MARK: - Opening things
func openDashboard() { NSWorkspace.shared.open(dashboardURL) }
func openAdmin() { NSWorkspace.shared.open(adminURL) }
}
+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
+625
View File
@@ -0,0 +1,625 @@
/* PCO Plan — client part.
The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is
streamed. What is shown is this tile's own choice (gear menu →
moduleApi.instanceSettings: show/hide checklist, top-bar template,
colors, text size); /admin only holds the connection settings. */
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
export default function create({ root, moduleApi }) {
let state = null;
let skew = 0; // server clock − local clock, ms
let feedOffline = false;
let stream = null;
let ticker = null;
let lastCurrentId = null;
let lastListKey = '';
let userScrolledAt = 0;
root.innerHTML = `
<div class="pp-wrap">
<header class="pp-head">
<div class="pp-plan">
<div class="pp-plan-title"></div>
<div class="pp-plan-sub"></div>
</div>
<div class="pp-clock">
<div class="pp-clock-label"></div>
<div class="pp-clock-value"></div>
</div>
</header>
<div class="pp-list"></div>
<footer class="pp-foot" hidden></footer>
</div>`;
const wrap = root.querySelector('.pp-wrap');
const headEl = root.querySelector('.pp-head');
const planTitleEl = root.querySelector('.pp-plan-title');
const planSubEl = root.querySelector('.pp-plan-sub');
const clockLabelEl = root.querySelector('.pp-clock-label');
const clockValueEl = root.querySelector('.pp-clock-value');
const listEl = root.querySelector('.pp-list');
const footEl = root.querySelector('.pp-foot');
/** This tile's display settings (gear menu) — read fresh on every render. */
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
const tilePrefs = cfg;
const serverNow = () => Date.now() + skew;
/* ── title-bar controls ─────────────────────────────────────────── */
const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG,
title: 'Re-read the plan from Planning Center now',
onClick() {
refreshBtn.classList.add('active');
moduleApi.fetch('/refresh', { method: 'POST' })
.catch(() => { /* the stream reports the outcome */ })
.finally(() => refreshBtn.classList.remove('active'));
},
});
function bumpTextSize(delta) {
const size = Number(tilePrefs().textSize) || 14;
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs();
}
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
/* ── formatting ─────────────────────────────────────────────────── */
function fmtClock(ms) {
if (!ms) return '';
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function fmtDur(totalSec, { signed = false } = {}) {
const sign = totalSec < 0 ? '-' : (signed ? '+' : '');
const s = Math.abs(Math.round(totalSec));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h) return `${sign}${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
}
function fmtMinutes(totalSec) {
const m = Math.round(totalSec / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
const rest = m % 60;
return rest ? `${h} h ${rest} min` : `${h} h`;
}
function typeLabel(itemType) {
switch (itemType) {
case 'song': return 'Song';
case 'media': return 'Media';
default: return '';
}
}
function plannedLength() {
return (state?.items || [])
.filter((it) => it.servicePosition === 'during')
.reduce((sum, it) => sum + (it.length || 0), 0);
}
/* ── which items to show ────────────────────────────────────────── */
function visibleItems() {
const items = Array.isArray(state?.items) ? state.items : [];
return items.filter((it) => {
if (it.itemType === 'header' && !on('showHeaders')) return false;
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
if (it.servicePosition === 'post' && !on('showPostService')) return false;
return true;
});
}
function noteFilter() {
const raw = String(cfg().noteCategories || '');
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
}
/* ── status dot ─────────────────────────────────────────────────── */
function renderStatus() {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
const live = state.live || {};
let extra = '';
if (live.following) {
extra = live.available
? (live.ppReachable ? ' · following ProPresenter' : ' · ProPresenter unreachable')
: ` · ${live.reason || 'ProPresenter feed unavailable'}`;
}
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
}
/* ── top bar: admin template with placeholders ──────────────────── */
const DEFAULT_TEMPLATE = '{series} • {part} | {serviceType} • {date}';
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
function templateValues() {
const plan = state?.plan || {};
const items = state?.items || [];
const planned = plannedLength();
return {
series: on('showSeries') ? plan.seriesTitle || '' : '',
part: on('showPart') ? plan.title || '' : '',
title: on('showPart') ? plan.title || '' : '',
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
date: on('showDate') ? plan.dates || '' : '',
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
time: on('showServiceTime') && plan.serviceStartsAt
? `${plan.serviceName && plan.serviceName !== 'Service' ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`
: '',
length: on('showTotalLength') && planned ? fmtMinutes(planned) : '',
items: items.filter((it) => it.itemType !== 'header').length ? String(items.filter((it) => it.itemType !== 'header').length) : '',
};
}
/**
* "{series} • {part} | {serviceType} • {date}" → up to two lines. Within a
* line, • (or ·) separates segments; a segment whose placeholders all came
* out empty is dropped, so a plan with no series just reads "Wk 2".
*/
function renderTemplate(template, values) {
const lines = String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2);
return lines.map((line) => {
const segments = line.split(/\s*[•·]\s*/);
const kept = [];
for (const seg of segments) {
let sawVar = false;
let allEmpty = true;
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
sawVar = true;
const v = values[name.toLowerCase()];
if (v) allEmpty = false;
return v || '';
}).trim();
if (sawVar && allEmpty) continue;
if (text) kept.push(text);
}
return kept.join(' • ');
});
}
function renderHead() {
const plan = state?.plan;
const show = on('showTopBar') && plan;
headEl.hidden = !show;
if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
planSubEl.textContent = line1 ? line2 || '' : '';
planSubEl.hidden = !planSubEl.textContent;
renderClock();
}
function renderClock() {
const plan = state?.plan;
const live = state?.live || {};
clockLabelEl.textContent = '';
clockValueEl.textContent = '';
clockValueEl.className = 'pp-clock-value';
if (!plan || headEl.hidden) return;
const now = serverNow();
if (on('showRunningClock') && live.following && live.serviceStartedAt) {
clockLabelEl.textContent = 'Running';
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
const drift = actual - planned;
if (planned && Math.abs(drift) >= 30) {
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
}
return;
}
if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
return;
}
if (on('showRunningClock') && plan.serviceStartsAt) {
clockLabelEl.textContent = 'Since start';
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
}
}
/* ── the list ───────────────────────────────────────────────────── */
function emptyMessage() {
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
if (!state) return { title: 'Connecting…', hint: '' };
if (!state.configured) {
return {
title: 'Planning Center not connected',
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.serviceTypeSet) {
return {
title: 'No service type selected',
hint: 'Pick the service type to follow from the list under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !state.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
}
if (!state.plan) {
return {
title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
};
}
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
return null;
}
function buildRow(item, number, notesWanted) {
const row = document.createElement('div');
row.className = 'pp-row';
row.dataset.id = item.id;
if (item.itemType === 'header') row.classList.add('is-header');
else row.classList.add(`is-${item.itemType === 'song' || item.itemType === 'media' ? item.itemType : 'item'}`);
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
const isHeader = item.itemType === 'header';
if (on('showItemNumbers')) {
const numCol = document.createElement('div');
numCol.className = 'pp-col-num';
numCol.textContent = isHeader ? '' : String(number);
row.appendChild(numCol);
}
if (on('showStartTimes')) {
const timeCol = document.createElement('div');
timeCol.className = 'pp-col-time';
timeCol.textContent = item.startsAt && !isHeader ? fmtClock(item.startsAt) : '';
row.appendChild(timeCol);
}
const main = document.createElement('div');
main.className = 'pp-col-main';
const titleLine = document.createElement('div');
titleLine.className = 'pp-title-line';
const titleEl = document.createElement('span');
titleEl.className = 'pp-title';
titleEl.textContent = item.title || item.song?.title || '(untitled)';
titleLine.appendChild(titleEl);
if (!isHeader) {
// Order after the title: key, NOW, type badge, arrangement.
if (on('showKey') && item.keyName) {
const key = document.createElement('span');
key.className = 'pp-key';
key.textContent = item.keyName;
titleLine.appendChild(key);
}
if (on('showNowIndicator')) {
const nowTag = document.createElement('span');
nowTag.className = 'pp-now-tag';
nowTag.textContent = 'Now';
titleLine.appendChild(nowTag);
}
if (on('showItemType') && typeLabel(item.itemType)) {
const badge = document.createElement('span');
badge.className = `pp-badge is-${item.itemType}`;
badge.textContent = typeLabel(item.itemType);
titleLine.appendChild(badge);
}
if (on('showArrangement') && item.arrangement) {
const arr = document.createElement('span');
arr.className = 'pp-arr';
arr.textContent = item.arrangement;
titleLine.appendChild(arr);
}
}
main.appendChild(titleLine);
if (!isHeader && on('showSongAuthor') && item.song && (item.song.author || item.song.ccli)) {
const author = document.createElement('div');
author.className = 'pp-author';
author.textContent = [item.song.author, item.song.ccli ? `CCLI ${item.song.ccli}` : ''].filter(Boolean).join(' · ');
main.appendChild(author);
}
if (on('showDescription') && item.description) {
const desc = document.createElement('div');
desc.className = 'pp-desc';
desc.textContent = item.description;
main.appendChild(desc);
}
if (on('showNotes') && item.notes?.length) {
const notes = item.notes.filter(notesWanted);
if (notes.length) {
const notesEl = document.createElement('div');
notesEl.className = 'pp-notes';
for (const note of notes) {
const n = document.createElement('div');
n.className = 'pp-note';
if (note.category) {
const cat = document.createElement('span');
cat.className = 'pp-note-cat';
cat.textContent = note.category;
n.appendChild(cat);
}
n.appendChild(document.createTextNode(note.content));
notesEl.appendChild(n);
}
main.appendChild(notesEl);
}
}
row.appendChild(main);
const wantRuntime = on('showItemRuntime') && state?.live?.following;
if (on('showLength') || wantRuntime) {
const lenCol = document.createElement('div');
lenCol.className = 'pp-col-len';
if (!isHeader) {
if (on('showLength')) {
const len = document.createElement('div');
len.className = 'pp-len';
len.textContent = item.length ? fmtDur(item.length) : '';
lenCol.appendChild(len);
}
if (wantRuntime) {
const el = document.createElement('div');
el.className = 'pp-elapsed';
lenCol.appendChild(el);
}
}
row.appendChild(lenCol);
}
return row;
}
function renderList() {
const empty = emptyMessage();
if (empty) {
listEl.innerHTML = '';
lastListKey = '';
const box = document.createElement('div');
box.className = 'pp-empty';
const t = document.createElement('div');
t.className = 'pp-empty-title';
t.textContent = empty.title;
box.appendChild(t);
if (empty.hint) {
const h = document.createElement('div');
h.className = 'pp-empty-hint';
h.textContent = empty.hint;
box.appendChild(h);
}
if (empty.link) {
const a = document.createElement('a');
a.className = 'pp-empty-link';
a.href = empty.link.href;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = empty.link.text;
box.appendChild(a);
}
listEl.appendChild(box);
return;
}
// Rebuild rows only when the plan content or the display settings
// changed; live highlighting and timers are applied in place.
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
if (key !== lastListKey) {
lastListKey = key;
listEl.innerHTML = '';
const notesWanted = noteFilter();
let number = 0;
for (const item of visibleItems()) {
if (item.itemType !== 'header') number += 1;
listEl.appendChild(buildRow(item, number, notesWanted));
}
lastCurrentId = null; // force the highlight + scroll pass
}
renderLiveMarks();
}
/** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() {
const live = state?.live || {};
const now = serverNow();
const currentId = live.following ? live.currentItemId || '' : '';
const history = live.history || {};
const byId = new Map(state.items.map((it) => [it.id, it]));
// Everything before the current item counts as done, matched or not.
const doneIds = new Set();
if (currentId) {
for (const it of state.items) {
if (it.id === currentId) break;
if (it.itemType !== 'header') doneIds.add(it.id);
}
}
const showNow = on('showNowIndicator');
for (const row of listEl.querySelectorAll('.pp-row')) {
const id = row.dataset.id;
const item = byId.get(id);
row.classList.toggle('is-current', showNow && id === currentId);
row.classList.toggle('is-done', on('dimCompleted') && doneIds.has(id));
const elapsedEl = row.querySelector('.pp-elapsed');
if (!elapsedEl || !item) continue;
const h = history[id];
elapsedEl.className = 'pp-elapsed';
if (!h) {
elapsedEl.textContent = '';
continue;
}
const elapsedSec = ((h.endedAt || now) - h.startedAt) / 1000;
elapsedEl.textContent = fmtDur(elapsedSec);
if (item.length) {
const over = elapsedSec - item.length;
if (over > item.length * 0.25 + 30) elapsedEl.classList.add('is-over-hard');
else if (over > 0) elapsedEl.classList.add('is-over');
}
if (!h.endedAt) elapsedEl.classList.add('is-running');
}
if (currentId !== lastCurrentId) {
lastCurrentId = currentId;
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
}
/* ── footer: what ProPresenter is doing ─────────────────────────── */
function renderFoot() {
const live = state?.live;
const show = on('showLiveFooter') && live?.following && state?.plan;
footEl.hidden = !show;
footEl.className = 'pp-foot';
if (!show) return;
if (!live.available) {
footEl.classList.add('is-muted');
footEl.textContent = live.reason || 'ProPresenter feed unavailable';
return;
}
if (!live.ppEnabled) {
footEl.classList.add('is-muted');
footEl.textContent = 'ProPresenter module has no host configured';
return;
}
if (!live.ppReachable) {
footEl.classList.add('is-down');
footEl.textContent = 'ProPresenter unreachable';
return;
}
if (!live.activeName) {
footEl.classList.add('is-muted');
footEl.textContent = 'Nothing live in ProPresenter';
return;
}
if (live.matched) {
footEl.classList.add('is-live');
footEl.textContent = `Live: ${live.activeName}`;
} else {
footEl.classList.add('is-unmatched');
footEl.textContent = `Live: ${live.activeName} — not in the plan`;
}
}
/* ── render + ticking ───────────────────────────────────────────── */
function render() {
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
const color = (value, fallback) => {
const s = String(value || '').trim();
return /^[#a-zA-Z0-9(),.%\s-]+$/.test(s) && s ? s : fallback;
};
wrap.style.setProperty('--pp-key', color(cfg().keyColor, '#4ea1ff'));
wrap.style.setProperty('--pp-header', color(cfg().headerColor, '#2ee59a'));
wrap.style.setProperty('--pp-current', color(cfg().currentColor, '#2ee59a'));
renderStatus();
if (!state) {
headEl.hidden = true;
renderList();
footEl.hidden = true;
return;
}
renderHead();
renderList();
renderFoot();
}
function applyPrefs() {
const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14));
wrap.style.setProperty('--pp-size', `${size}px`);
lastListKey = '';
render();
}
function tick() {
if (!state?.plan) return;
renderClock();
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
}
/* ── live feed ──────────────────────────────────────────────────── */
function accept(payload) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
feedOffline = false;
render();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
feedOffline = false;
render();
},
error() {
feedOffline = true;
render();
},
events: {
state(ev) {
try {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
},
});
}
function onUserScroll() {
userScrolledAt = Date.now();
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
applyPrefs();
moduleApi.setStatus('connecting', 'Connecting…');
moduleApi.fetch('/state')
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
listEl.addEventListener('wheel', onUserScroll, { passive: true });
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
ticker = setInterval(tick, 1000);
},
stop() {
clearInterval(ticker);
ticker = null;
stream?.close();
stream = null;
listEl.removeEventListener('wheel', onUserScroll);
listEl.removeEventListener('touchmove', onUserScroll);
root.innerHTML = '';
},
onConfigChange() {
// Admin holds only connection settings; the server re-inits and its
// stream re-sends the state. Nothing cached here to refresh.
},
};
}
+124
View File
@@ -0,0 +1,124 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.3.0",
"proddash": ">=1.3.0",
"description": "Order of service from Planning Center Services, following ProPresenter live",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 5, "h": 6 },
"configGroups": {
"Planning Center credentials": {
"collapsed": "whenSet",
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
}
},
"configSchema": {
"appId": {
"type": "string",
"label": "Application ID",
"default": "",
"group": "Planning Center credentials"
},
"secret": {
"type": "password",
"label": "Secret",
"default": "",
"group": "Planning Center credentials"
},
"accessToken": {
"type": "password",
"label": "OAuth 2.0 access token (instead of Application ID + Secret)",
"default": "",
"group": "Planning Center credentials"
},
"serviceTypeId": {
"type": "select",
"label": "Service type",
"default": "",
"optionsRoute": "/service-types",
"help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
},
"planId": {
"type": "select",
"label": "Plan",
"default": "",
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
"optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId",
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
},
"pollSeconds": {
"type": "number",
"label": "Re-read the plan every N seconds",
"default": 30
},
"followProPresenter": {
"type": "switch",
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
"default": true
}
},
"instanceGroups": {
"Top bar": {
"collapsed": false
},
"Colors": {
"collapsed": false,
"columns": 2
},
"Show / hide": {
"collapsed": true,
"columns": 3,
"help": "Untick anything this tile should not show."
}
},
"instanceSchema": {
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
"noteCategories": {
"type": "string",
"label": "Item note categories to show (comma-separated, blank = all)",
"default": ""
},
"headerTemplate": {
"type": "string",
"label": "Top bar text",
"default": "{series} • {part} | {serviceType} • {date}",
"group": "Top bar",
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
},
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
}
}
+913
View File
@@ -0,0 +1,913 @@
'use strict';
/**
* PCO Plan — server part.
*
* Owns the one Planning Center Services connection, keeps the current plan
* (order of service) for the configured service type in memory, and fans it
* out to every tile over SSE. Browsers never talk to Planning Center.
*
* GET /state → { state, now } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
* POST /refresh → re-read the plan now
* GET /service-types → { options } for the admin "Service type" select,
* grouped by Planning Center folder path
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
*
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
* bearer token. Responses are JSON:API — `data`, `included`, `links.next`.
* GET /folders, /service_types (setup browsing)
* GET /service_types/{st}/plans?filter=future|past (pick the plan)
* GET /service_types/{st}/plans/{id} (title, dates…)
* GET …/plans/{id}/plan_times (service start times)
* GET …/plans/{id}/items?include=song,arrangement,item_notes
*
* Phase 2 — following ProPresenter: instead of talking to ProPresenter a
* second time, this module reads the propresenter-now-next module's own SSE
* feed over loopback (/api/modules/propresenter-now-next/stream) and matches
* the live presentation name to a plan item. Item start/end timestamps are
* tracked here, once, so every tile agrees on the runtime. The shell's port
* is guessed the way the shell decides it and then confirmed from the Host
* header of the first tile request.
*
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at
* tools/pco-mock.js for development.
*/
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const SERVICES = '/services/v2';
/** Abort a hung Planning Center request well before the poll cadence. */
const REQUEST_TIMEOUT_MS = 12000;
/** Fastest allowed plan re-read (PCO rate limit is 100 requests / 20 s per app). */
const MIN_POLL_S = 10;
/** Backoff after a failed plan read. */
const RETRY_MS = 15000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** The Now/Next module whose feed we follow. */
const NOW_NEXT_ID = 'propresenter-now-next';
const NOW_NEXT_RETRY_MS = 3000;
const NOW_NEXT_MISSING_RETRY_MS = 15000;
/** Minimum name-similarity to call a ProPresenter document a plan item. */
const MATCH_THRESHOLD = 0.6;
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
/* ── small helpers ──────────────────────────────────────────────────── */
function clearedState() {
return {
configured: false, // credentials present
serviceTypeSet: false, // a service type is chosen
reachable: false, // last Planning Center read succeeded
lastError: '',
lastFetched: 0,
apiBase: API_BASE,
serviceType: { id: '', name: '' },
plan: null,
items: [],
live: clearedLive(),
};
}
function clearedLive() {
return {
following: false, // admin switch on
available: false, // Now/Next feed reachable
reason: '', // why not available
ppEnabled: false,
ppReachable: false,
activeName: '', // what ProPresenter has live right now
matched: false, // activeName maps to a plan item
currentItemId: '',
currentScore: 0,
serviceStartedAt: 0, // first matched item start, ms epoch
history: {}, // itemId → { startedAt, endedAt }
};
}
/**
* First guess at the shell's port, so the loopback Now/Next feed lands on this
* ProdDash before any browser has talked to us. Mirrors how the shell decides:
* PORT env, then this machine's proddash.json in the data directory, then the
* checked-in config/proddash.json, then 24500. The guess is corrected from the
* Host header of the first request that reaches a route (see learnPort).
*/
function guessShellPort() {
const env = Number(process.env.PORT);
if (env) return env;
const home = os.homedir();
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
const root = path.join(__dirname, '..', '..');
const dataDir = fromEnv
? path.resolve(root, fromEnv)
: process.platform === 'win32'
? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash')
: process.platform === 'darwin'
? path.join(home, 'Library', 'Application Support', 'ProdDash')
: path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) {
try {
const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port);
if (port) return port;
} catch { /* try the next one */ }
}
return 24500;
}
/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */
function portFromHost(hostHeader) {
const m = /:(\d+)$/.exec(String(hostHeader || '').trim());
if (m) return Number(m[1]) || 0;
return hostHeader ? 80 : 0;
}
function localYmd(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
function cleanTitle(raw) {
const original = String(raw || '').trim();
let s = original;
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
s = s.replace(/\s[-–—]+\s/g, ' ');
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
s = s.replace(/\s{2,}/g, ' ').trim();
return s || original;
}
function normalize(raw) {
return cleanTitle(raw)
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[’'`]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
function tokens(norm) {
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
}
/** 0..1 similarity between two titles. */
function similarity(a, b) {
const na = normalize(a);
const nb = normalize(b);
if (!na || !nb) return 0;
if (na === nb) return 1;
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
const ta = tokens(na);
const tb = tokens(nb);
if (!ta.size || !tb.size) return 0;
let common = 0;
for (const t of ta) if (tb.has(t)) common += 1;
return (2 * common) / (ta.size + tb.size); // Dice coefficient
}
/* ── Planning Center client ─────────────────────────────────────────── */
function createPcoClient(config) {
const token = String(config.accessToken || '').trim();
const appId = String(config.appId || '').trim();
const secret = String(config.secret || '');
let auth = '';
if (token) auth = `Bearer ${token}`;
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
async function get(target, params) {
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
let response;
try {
response = await fetch(url, {
headers: { Authorization: auth, Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
const e = new Error(`Planning Center unreachable: ${err?.message || err}`);
e.network = true;
throw e;
}
if (!response.ok) {
let detail = '';
try {
const body = await response.json();
detail = body?.errors?.[0]?.detail || body?.errors?.[0]?.title || '';
} catch { /* no JSON body */ }
const e = new Error(
response.status === 401
? 'Planning Center rejected the credentials (401)'
: `Planning Center ${response.status}${detail ? `: ${detail}` : ''}`
);
e.status = response.status;
if (response.status === 429) e.retryAfter = Number(response.headers.get('retry-after')) || 10;
throw e;
}
return response.json();
}
/** Follow JSON:API `links.next` until the collection is complete. */
async function getAll(target, params) {
const data = [];
const included = [];
let url = target;
let query = { per_page: 100, ...(params || {}) };
for (let page = 0; page < 30; page += 1) {
const body = await get(url, query);
if (Array.isArray(body?.data)) data.push(...body.data);
if (Array.isArray(body?.included)) included.push(...body.included);
const next = body?.links?.next;
if (!next) break;
url = next;
query = {}; // `next` already carries the query string
}
return { data, included };
}
return { configured: Boolean(auth), method: token ? 'oauth' : 'pat', get, getAll };
}
/* ── plan shaping ───────────────────────────────────────────────────── */
function pickServiceTime(planTimes, nowMs) {
const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt);
const pool = services.length ? services : planTimes.filter((t) => t.startsAt);
if (!pool.length) return null;
pool.sort((a, b) => a.startsAt - b.startsAt);
// The service that is running or up next; after the last one, the last one.
const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs);
return upcoming || pool[pool.length - 1];
}
/** Scheduled start of every item from the chosen service start time. */
function scheduleItems(items, serviceStartMs) {
for (const item of items) item.startsAt = 0;
if (!serviceStartMs) return;
let t = serviceStartMs;
for (const item of items) {
if (item.servicePosition !== 'during') continue;
item.startsAt = t;
t += (item.length || 0) * 1000;
}
// Post-service items run on from the end of the service…
for (const item of items) {
if (item.servicePosition !== 'post') continue;
item.startsAt = t;
t += (item.length || 0) * 1000;
}
// …and pre-service items are counted backwards to end at the service start.
let back = serviceStartMs;
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
if (item.servicePosition !== 'pre') continue;
back -= (item.length || 0) * 1000;
item.startsAt = back;
}
}
function shapeItems(itemsBody) {
const included = new Map();
for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec);
const rel = (item, name) => item?.relationships?.[name]?.data;
const items = (itemsBody.data || []).map((it) => {
const a = it.attributes || {};
const songRef = rel(it, 'song');
const song = songRef ? included.get(`Song/${songRef.id}`) : null;
const arrRef = rel(it, 'arrangement');
const arrangement = arrRef ? included.get(`Arrangement/${arrRef.id}`) : null;
const noteRefs = Array.isArray(rel(it, 'item_notes')) ? rel(it, 'item_notes') : [];
const notes = noteRefs
.map((ref) => included.get(`ItemNote/${ref.id}`))
.filter(Boolean)
.map((n) => ({
category: String(n.attributes?.category_name || ''),
content: String(n.attributes?.content || '').trim(),
}))
.filter((n) => n.content);
return {
id: String(it.id),
sequence: Number(a.sequence) || 0,
title: String(a.title || '').trim(),
itemType: String(a.item_type || 'item'),
length: Number(a.length) || 0,
servicePosition: String(a.service_position || 'during'),
description: String(a.description || '').trim(),
keyName: String(a.key_name || '').trim(),
song: song
? {
title: String(song.attributes?.title || ''),
author: String(song.attributes?.author || ''),
ccli: song.attributes?.ccli_number ? String(song.attributes.ccli_number) : '',
}
: null,
arrangement: arrangement ? String(arrangement.attributes?.name || '') : '',
notes,
startsAt: 0,
};
});
items.sort((x, y) => x.sequence - y.sequence);
return items;
}
function shapePlan(planRec, serviceTypeId, planTimes) {
const a = planRec?.attributes || {};
return {
id: String(planRec?.id || ''),
serviceTypeId: String(serviceTypeId),
title: String(a.title || '').trim(),
seriesTitle: String(a.series_title || '').trim(),
dates: String(a.dates || '').trim(),
shortDates: String(a.short_dates || '').trim(),
sortDate: a.sort_date ? Date.parse(a.sort_date) || 0 : 0,
totalLength: Number(a.total_length) || 0,
itemsCount: Number(a.items_count) || 0,
url: String(a.planning_center_url || ''),
planTimes,
serviceStartsAt: 0,
serviceEndsAt: 0,
serviceName: '',
};
}
/* ── the module ─────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
const streams = new Set();
const pco = createPcoClient(config);
const serviceTypeId = String(config.serviceTypeId || '').trim();
const pinnedPlanId = String(config.planId || '').trim();
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
const follow = config.followProPresenter !== false;
let state = clearedState();
state.configured = pco.configured;
state.serviceTypeSet = Boolean(serviceTypeId);
state.serviceType = { id: serviceTypeId, name: '' };
state.live.following = follow;
let stopped = false;
let pollTimer = null;
let inFlight = null;
let lastFrame = '';
/* — broadcasting — */
function frameOf() {
return JSON.stringify(state);
}
function broadcast(force) {
const frame = frameOf();
if (!force && frame === lastFrame) return;
lastFrame = frame;
if (!streams.size) return;
const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
/* — plan reading — */
function planPath(planId) {
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
}
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
async function listPlans(stId, { future = 5, past = 3 } = {}) {
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
const [fut, pst] = await Promise.all([
pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }),
pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }),
]);
const byId = new Map();
for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) {
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
if (!rec?.id || !Number.isFinite(sortDate)) continue;
byId.set(String(rec.id), { rec, sortDate });
}
return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
}
/** Today's plan, else the next upcoming one, else the most recent past one. */
function pickPlan(plans) {
if (!plans.length) return null;
const now = Date.now();
const today = localYmd(new Date(now));
const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today);
if (todays) return todays.rec;
const next = plans.find((p) => p.sortDate > now);
if (next) return next.rec;
return plans[plans.length - 1].rec;
}
async function choosePlanId(stId) {
return pickPlan(await listPlans(stId));
}
async function readPlanTimes(planId) {
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
return (body.data || [])
.map((t) => ({
id: String(t.id),
name: String(t.attributes?.name || '').trim(),
timeType: String(t.attributes?.time_type || 'service'),
startsAt: Date.parse(t.attributes?.starts_at || '') || 0,
endsAt: Date.parse(t.attributes?.ends_at || '') || 0,
}))
.filter((t) => t.startsAt)
.sort((x, y) => x.startsAt - y.startsAt);
}
async function readItems(planId) {
const target = `${planPath(planId)}/items`;
try {
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
} catch (err) {
// An account/API that refuses one of the includes still has items.
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
log(`items include rejected (${err.message}) — reading items without includes`);
return shapeItems(await pco.getAll(target));
}
throw err;
}
}
async function readPlan() {
if (!pco.configured || !serviceTypeId) return;
let planRec = null;
if (pinnedPlanId) {
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
} else {
const chosen = await choosePlanId(serviceTypeId);
// Re-read the chosen plan by id: the list endpoint's attributes are the
// same, but this keeps one code path and picks up edits immediately.
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
}
if (!state.serviceType.name) {
try {
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
} catch (err) {
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
throw err;
}
}
if (!planRec) {
state.plan = null;
state.items = [];
state.reachable = true;
state.lastError = '';
state.lastFetched = Date.now();
return;
}
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
const plan = shapePlan(planRec, serviceTypeId, planTimes);
const service = pickServiceTime(planTimes, Date.now());
if (service) {
plan.serviceStartsAt = service.startsAt;
plan.serviceEndsAt = service.endsAt;
plan.serviceName = service.name;
}
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
if (state.plan?.id !== plan.id) {
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`);
else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim());
resetLiveTracking();
}
state.plan = plan;
state.items = items;
state.reachable = true;
state.lastError = '';
state.lastFetched = Date.now();
}
function schedulePoll(ms) {
if (stopped) return;
clearTimeout(pollTimer);
pollTimer = setTimeout(poll, ms);
pollTimer.unref?.();
}
async function poll() {
if (stopped) return;
if (inFlight) return inFlight;
inFlight = (async () => {
let delay = pollMs;
try {
await readPlan();
} catch (err) {
state.reachable = false;
state.lastError = err?.message || String(err);
delay = err?.retryAfter ? (err.retryAfter + 1) * 1000 : RETRY_MS;
log(`plan read failed: ${state.lastError}`);
}
broadcast();
schedulePoll(delay);
})();
try {
await inFlight;
} finally {
inFlight = null;
}
}
/* — phase 2: follow the ProPresenter Now/Next feed over loopback — */
let nnRequest = null;
let nnRetryTimer = null;
let lastLiveName = null;
const guessedPort = guessShellPort();
let nnPort = guessedPort;
/** Learned ports that refused the loopback connection (a reverse proxy's port, say). */
const badPorts = new Set();
/** A browser reached us on this Host — normally the port the shell really serves on. */
function learnPort(hostHeader) {
const port = portFromHost(hostHeader);
if (!port || port === nnPort || badPorts.has(port)) return;
log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`);
nnPort = port;
if (!follow || stopped) return;
clearTimeout(nnRetryTimer);
try { nnRequest?.destroy(); } catch { /* gone */ }
nnRequest = null;
connectNowNext();
}
function resetLiveTracking() {
const keep = state.live;
state.live = {
...clearedLive(),
following: keep.following,
available: keep.available,
reason: keep.reason,
ppEnabled: keep.ppEnabled,
ppReachable: keep.ppReachable,
activeName: keep.activeName,
};
lastLiveName = null;
}
/** Best plan item for a live presentation name, preferring the ones ahead of us. */
function matchItem(name) {
const items = state.items.filter((it) => it.itemType !== 'header');
if (!items.length) return null;
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
let best = null;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
if (score < MATCH_THRESHOLD) continue;
// Distance ahead of the current item (wrapping so items behind rank last).
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
if (
!best
|| score > best.score + 0.001
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
) {
best = { item, score, dist };
}
}
return best;
}
function applyLiveView(view) {
const live = state.live;
live.available = true;
live.reason = '';
live.ppEnabled = Boolean(view?.enabled);
live.ppReachable = Boolean(view?.reachable);
const activeItem = Array.isArray(view?.items) && view.activeIndex >= 0 ? view.items[view.activeIndex] : null;
const name = String(activeItem?.name || view?.activePresentationName || '').trim();
live.activeName = name;
if (!name) {
// Cleared output between items is normal — the current item stays current.
lastLiveName = '';
broadcast();
return;
}
if (name === lastLiveName) return;
lastLiveName = name;
const hit = matchItem(name);
live.matched = Boolean(hit);
if (hit) {
live.currentScore = hit.score;
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
}
broadcast();
}
function startItem(itemId) {
const live = state.live;
const now = Date.now();
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
live.history[live.currentItemId].endedAt = now;
}
live.currentItemId = itemId;
live.history[itemId] = { startedAt: now, endedAt: 0 };
if (!live.serviceStartedAt) live.serviceStartedAt = now;
const item = state.items.find((it) => it.id === itemId);
log(`live item → ${item?.title || itemId}`);
}
function nowNextUnavailable(reason, retryMs) {
const live = state.live;
if (live.available || live.reason !== reason) {
live.available = false;
live.reason = reason;
live.ppReachable = false;
broadcast();
}
if (stopped) return;
clearTimeout(nnRetryTimer);
nnRetryTimer = setTimeout(connectNowNext, retryMs);
nnRetryTimer.unref?.();
}
function connectNowNext() {
if (stopped || !follow) return;
const port = nnPort;
const req = http.get(
{ host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } },
(res) => {
if (res.statusCode !== 200) {
res.resume();
const why = res.statusCode === 404
? 'ProPresenter module is disabled or not installed'
: `ProPresenter module feed answered ${res.statusCode}`;
return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS);
}
res.setEncoding('utf8');
let buffer = '';
res.on('data', (chunk) => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = 'message';
let data = '';
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (event !== 'state' || !data) continue;
try {
applyLiveView(JSON.parse(data));
} catch { /* a bad frame is not fatal */ }
}
});
res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS));
res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS));
}
);
req.on('error', (err) => {
if (req !== nnRequest) return; // superseded by a reconnect on the learned port
if (port !== guessedPort) {
// The Host header lied (reverse proxy, port forward) — that port is
// not this ProdDash. Go back to the guess and never trust it again.
badPorts.add(port);
nnPort = guessedPort;
log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`);
}
nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS);
});
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
nnRequest = req;
}
/* — start — */
if (!pco.configured) {
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
log(state.lastError);
} else if (!serviceTypeId) {
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
log(state.lastError);
} else {
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
schedulePoll(0);
}
if (follow) {
state.live.reason = 'Connecting to the ProPresenter module feed';
connectNowNext();
}
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = {
streams,
pco,
getState: () => state,
learnPort,
refresh() {
clearTimeout(pollTimer);
return poll();
},
listPlans,
pickPlan,
};
return {
stop() {
stopped = true;
clearInterval(heartbeat);
clearTimeout(pollTimer);
clearTimeout(nnRetryTimer);
try { nnRequest?.destroy(); } catch { /* gone */ }
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health() {
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' };
if (!state.reachable) {
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
}
const name = state.serviceType.name || serviceTypeId;
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
return { status: 'ok', message: `Following ${name}${plan}${live}` };
},
};
},
routes({ log }) {
const json = (res, status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
return {
'GET /state': (req, res) => {
current?.learnPort(req.headers.host);
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
},
'GET /stream': (req, res) => {
current?.learnPort(req.headers.host);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
const state = current ? current.getState() : clearedState();
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
if (!current) return void res.end();
const { streams } = current;
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
streams.add(res);
req.on('close', () => streams.delete(res));
},
'POST /refresh': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
current.refresh().then(
() => json(res, 200, { ok: true, state: current?.getState() }),
(err) => json(res, 502, { error: err?.message || String(err) })
);
},
/**
* Options for the admin page's "Service type" select: every service
* type, grouped by its Planning Center folder path — so the folders
* are browsable as <optgroup>s right in the admin form.
*/
'GET /service-types': async (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const { pco } = current;
if (!pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
try {
const [folderBody, stBody] = await Promise.all([
pco.getAll(`${SERVICES}/folders`),
pco.getAll(`${SERVICES}/service_types`),
]);
const folders = new Map();
for (const f of folderBody.data || []) {
folders.set(String(f.id), {
id: String(f.id),
name: String(f.attributes?.name || 'Folder'),
parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '',
});
}
const serviceTypes = (stBody.data || []).map((st) => ({
id: String(st.id),
name: String(st.attributes?.name || 'Service type'),
frequency: String(st.attributes?.frequency || ''),
parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '',
}));
// Fill in any folder the list did not include (some accounts only
// list root folders) by fetching the missing parents individually.
const missing = new Set();
for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId);
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId);
let guard = 0;
while (missing.size && guard < 50) {
const id = missing.values().next().value;
missing.delete(id);
guard += 1;
try {
const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data;
const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : '';
folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId });
if (parentId && !folders.has(parentId)) missing.add(parentId);
} catch {
folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
}
}
// "Campuses › North Campus" — the folder path from the root down.
const pathOf = (parentId) => {
const names = [];
let id = parentId;
const seen = new Set();
while (id && folders.has(id) && !seen.has(id)) {
seen.add(id);
names.unshift(folders.get(id).name);
id = folders.get(id).parentId;
}
return names.join(' › ');
};
const options = serviceTypes
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
json(res, 200, { options });
} catch (err) {
log(`service type list failed: ${err?.message || err}`);
json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) });
}
},
/**
* Options for the admin page's "Plan" select, for the service type the
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
* plans, with the one Automatic would choose marked.
*/
'GET /plans': async (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const { pco } = current;
const params = new URLSearchParams(req.search || '');
const stId = String(params.get('serviceTypeId') || '').trim();
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
try {
const plans = await current.listPlans(stId, { future: 8, past: 6 });
const picked = current.pickPlan(plans);
const now = Date.now();
const options = [auto];
// Upcoming first (soonest at the top), then the recent ones.
const ordered = [
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
];
for (const { rec, sortDate } of ordered) {
const a = rec.attributes || {};
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
if (a.series_title) bits.push(String(a.series_title));
if (a.title) bits.push(String(a.title));
let label = bits.join(' — ');
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
}
json(res, 200, { options });
} catch (err) {
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
}
},
};
},
};
+367
View File
@@ -0,0 +1,367 @@
/* PCO Plan module — scoped to its tiles, shell variables only (plus the
admin-chosen --pp-key color for song keys). Everything scales from
--pp-size (set from the tile's text-size setting). */
[data-module="pco-plan"] .pp-wrap {
--pp-size: 14px;
--pp-key: #4ea1ff;
--pp-header: var(--accent);
--pp-current: var(--accent);
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
font-size: var(--pp-size);
color: var(--text);
}
/* ── top bar ── */
[data-module="pco-plan"] .pp-head {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1em;
padding: 0.6em 0.85em 0.5em;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
[data-module="pco-plan"] .pp-head[hidden] { display: none; }
[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; }
[data-module="pco-plan"] .pp-plan-title {
font-size: 1.1em;
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pp-plan-sub {
margin-top: 0.15em;
font-size: 0.82em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pp-plan-sub[hidden] { display: none; }
[data-module="pco-plan"] .pp-clock {
flex: none;
text-align: right;
}
[data-module="pco-plan"] .pp-clock-label {
font-size: 0.7em;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
}
[data-module="pco-plan"] .pp-clock-value {
font-size: 1.25em;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
[data-module="pco-plan"] .pp-clock-value:empty,
[data-module="pco-plan"] .pp-clock-label:empty { display: none; }
[data-module="pco-plan"] .pp-clock-value.is-behind { color: var(--warn); }
[data-module="pco-plan"] .pp-clock-value.is-ahead { color: var(--accent); }
/* ── list ── */
[data-module="pco-plan"] .pp-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0.35em 0.5em 0.6em;
overscroll-behavior: contain;
scroll-padding: 2em;
}
[data-module="pco-plan"] .pp-row {
display: flex;
align-items: flex-start;
gap: 0.6em;
padding: 0.45em 0.5em;
border-radius: 0.5em;
border-left: 3px solid transparent;
transition: background 0.2s ease, opacity 0.3s ease;
}
[data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; }
[data-module="pco-plan"] .pp-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
/* Optional background tint per item type (admin "Background color per item type"). */
[data-module="pco-plan"] .tint-types .pp-row.is-song { background: color-mix(in srgb, var(--accent) 9%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-media { background: color-mix(in srgb, var(--warn) 10%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-item { background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-song:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-media:hover { background: color-mix(in srgb, var(--warn) 15%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
/* Section headers: a band that runs edge to edge across the tile (negative
margins cancel the list padding) so a new part of the service is
unmistakable — tile-chosen color, solid or fading to the right. The
left padding keeps the text aligned with the other rows' text. */
[data-module="pco-plan"] .pp-row.is-header {
margin: 0.9em -0.5em 0;
padding: 0.5em 1em 0.45em calc(1em + 3px);
align-items: center;
border-left: none;
border-radius: 0;
background: color-mix(in srgb, var(--pp-header) 22%, transparent);
}
[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; }
[data-module="pco-plan"] .pp-row + .pp-row.is-header { margin-top: 0.9em; }
[data-module="pco-plan"] .pp-row.is-header:hover { background: color-mix(in srgb, var(--pp-header) 28%, transparent); }
[data-module="pco-plan"] .header-gradient .pp-row.is-header {
background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 30%, transparent), color-mix(in srgb, var(--pp-header) 6%, transparent) 70%, transparent);
}
[data-module="pco-plan"] .header-gradient .pp-row.is-header:hover {
background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 36%, transparent), color-mix(in srgb, var(--pp-header) 8%, transparent) 70%, transparent);
}
[data-module="pco-plan"] .pp-row.is-header .pp-title {
font-size: 0.85em;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text);
}
[data-module="pco-plan"] .pp-row.is-header .pp-col-time,
[data-module="pco-plan"] .pp-row.is-header .pp-col-num,
[data-module="pco-plan"] .pp-row.is-header .pp-col-len { visibility: hidden; }
[data-module="pco-plan"] .pp-row.is-pre .pp-title,
[data-module="pco-plan"] .pp-row.is-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
[data-module="pco-plan"] .pp-row.is-done { opacity: 0.45; }
[data-module="pco-plan"] .pp-row.is-done:hover { opacity: 0.8; }
[data-module="pco-plan"] .pp-row.is-current {
border-left-color: var(--pp-current);
background: color-mix(in srgb, var(--pp-current) 16%, transparent);
opacity: 1;
}
[data-module="pco-plan"] .pp-col-num {
flex: none;
width: 1.6em;
padding-top: 0.1em;
font-size: 0.8em;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--muted);
text-align: right;
}
[data-module="pco-plan"] .pp-col-time {
flex: none;
width: 4.6em;
padding-top: 0.1em;
font-size: 0.85em;
font-variant-numeric: tabular-nums;
color: var(--muted);
text-align: right;
white-space: nowrap;
}
[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--pp-current); font-weight: 700; }
[data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; }
[data-module="pco-plan"] .pp-title-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.2em 0.55em;
min-width: 0;
}
[data-module="pco-plan"] .pp-title {
font-weight: 600;
line-height: 1.3;
overflow-wrap: anywhere;
}
/* The song key: right after the title, larger and in the admin-chosen
color (blue by default) so the band can read it from across the booth. */
[data-module="pco-plan"] .pp-key {
font-size: 1.15em;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0.02em;
padding: 0.05em 0.5em;
border-radius: 0.4em;
color: var(--pp-key);
background: color-mix(in srgb, var(--pp-key) 16%, transparent);
border: 1px solid color-mix(in srgb, var(--pp-key) 45%, transparent);
white-space: nowrap;
}
[data-module="pco-plan"] .pp-now-tag {
display: none;
padding: 0.05em 0.45em;
border-radius: 999px;
background: var(--pp-current);
color: var(--bg);
font-size: 0.65em;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
[data-module="pco-plan"] .pp-row.is-current .pp-now-tag { display: inline-block; }
[data-module="pco-plan"] .pp-badge,
[data-module="pco-plan"] .pp-arr {
font-size: 0.7em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.1em 0.45em;
border-radius: 0.35em;
white-space: nowrap;
}
[data-module="pco-plan"] .pp-badge {
color: var(--muted);
border: 1px solid var(--border);
}
[data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
[data-module="pco-plan"] .pp-badge.is-media { color: color-mix(in srgb, var(--warn) 85%, var(--text)); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
[data-module="pco-plan"] .pp-arr { color: var(--muted); text-transform: none; letter-spacing: 0; font-weight: 600; }
[data-module="pco-plan"] .pp-author {
margin-top: 0.1em;
font-size: 0.78em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pp-desc {
margin-top: 0.15em;
font-size: 0.86em;
line-height: 1.35;
color: color-mix(in srgb, var(--text) 80%, var(--muted));
white-space: pre-line;
overflow-wrap: anywhere;
}
[data-module="pco-plan"] .pp-notes {
margin-top: 0.3em;
display: flex;
flex-direction: column;
gap: 0.2em;
}
[data-module="pco-plan"] .pp-note {
font-size: 0.82em;
line-height: 1.35;
padding: 0.25em 0.5em;
border-radius: 0.35em;
background: color-mix(in srgb, var(--warn) 9%, transparent);
border-left: 2px solid color-mix(in srgb, var(--warn) 60%, transparent);
color: var(--text);
white-space: pre-line;
overflow-wrap: anywhere;
}
[data-module="pco-plan"] .pp-note-cat {
display: inline-block;
margin-right: 0.5em;
font-size: 0.8em;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--warn);
}
[data-module="pco-plan"] .pp-col-len {
flex: none;
min-width: 3.2em;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
[data-module="pco-plan"] .pp-len {
font-size: 0.85em;
color: var(--muted);
padding-top: 0.1em;
}
[data-module="pco-plan"] .pp-elapsed {
font-size: 0.9em;
font-weight: 700;
color: var(--text);
opacity: 0.8;
}
[data-module="pco-plan"] .pp-elapsed:empty { display: none; }
[data-module="pco-plan"] .pp-elapsed.is-running { color: var(--accent); opacity: 1; }
[data-module="pco-plan"] .pp-elapsed.is-over { color: var(--warn); opacity: 1; }
[data-module="pco-plan"] .pp-elapsed.is-over-hard { color: var(--danger); opacity: 1; }
/* ── empty / degraded states ── */
[data-module="pco-plan"] .pp-empty {
padding: 12% 1.2em 1em;
text-align: center;
color: var(--muted);
}
[data-module="pco-plan"] .pp-empty-title {
font-size: 1.1em;
font-weight: 700;
color: var(--text);
}
[data-module="pco-plan"] .pp-empty-hint {
margin-top: 0.4em;
font-size: 0.9em;
line-height: 1.4;
}
[data-module="pco-plan"] .pp-empty-link {
display: inline-block;
margin-top: 0.9em;
padding: 0.35em 0.9em;
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
border-radius: 999px;
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
[data-module="pco-plan"] .pp-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
[data-module="pco-plan"] .pp-wrap.is-offline .pp-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
[data-module="pco-plan"] .pp-wrap.is-offline .pp-list { opacity: 0.6; }
/* ── footer: ProPresenter match line ── */
[data-module="pco-plan"] .pp-foot {
flex: none;
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.4em 0.85em;
border-top: 1px solid var(--border);
font-size: 0.8em;
font-weight: 600;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pp-foot[hidden] { display: none; }
[data-module="pco-plan"] .pp-foot::before {
content: '';
flex: none;
width: 0.55em;
height: 0.55em;
border-radius: 999px;
background: var(--border);
}
[data-module="pco-plan"] .pp-foot.is-live { color: var(--text); }
[data-module="pco-plan"] .pp-foot.is-live::before { background: var(--accent); box-shadow: 0 0 0 0.2em color-mix(in srgb, var(--accent) 18%, transparent); }
[data-module="pco-plan"] .pp-foot.is-unmatched { color: var(--warn); }
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
+4 -1
View File
@@ -1,9 +1,12 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.2.0",
"version": "1.2.1",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript stream",
"permissions": [
{ "kind": "local-network", "reason": "Reaching the ProdCom API on this network" }
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
+4 -1
View File
@@ -1,9 +1,12 @@
{
"id": "propresenter-now-next",
"name": "ProPresenter",
"version": "1.0.0",
"version": "1.0.1",
"proddash": ">=1.1.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",
+154 -12
View File
@@ -11,6 +11,45 @@
const LTC_KEY = '__ltc__';
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
counts every frame in between locally. That needs exact frame↔timecode
conversion, including 29.97 drop-frame (frames 00 and 01 don't exist at
the top of a minute, except every tenth minute). */
export function parseTc(str) {
const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;.](\d{1,2})$/.exec(String(str || '').trim());
if (!m) return null;
return { h: Number(m[1]), m: Number(m[2]), s: Number(m[3]), f: Number(m[4]) };
}
export function tcToFrames(tc, nominal, df) {
let n = (tc.h * 3600 + tc.m * 60 + tc.s) * nominal + tc.f;
if (df) {
const mins = tc.h * 60 + tc.m;
n -= 2 * (mins - Math.floor(mins / 10));
}
return n;
}
export function framesToTc(n, nominal, df) {
// 10 DF minutes hold 17982 frames (the first minute 1800, the rest 1798).
const perDay = df ? 24 * 6 * 17982 : 24 * 3600 * nominal;
n = ((n % perDay) + perDay) % perDay;
if (df) {
const d = Math.floor(n / 17982);
const m10 = n % 17982;
const extra = m10 < 1800 ? 0 : Math.floor((m10 - 1800) / 1798) + 1;
const totalMin = d * 10 + extra;
// Re-add the two dropped frames per dropped minute, then read as 30fps.
n += 2 * (totalMin - Math.floor(totalMin / 10));
}
const f = n % nominal;
const secs = Math.floor(n / nominal);
const pad = (v) => String(v).padStart(2, '0');
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`;
}
export default function create({ root, moduleApi }) {
let state = null;
let feedOffline = false;
@@ -61,7 +100,9 @@ export default function create({ root, moduleApi }) {
function menuRows() {
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name }));
rows.push({ key: LTC_KEY, name: 'LTC timecode' });
// No LTC row while the listener is off in Admin — the item shouldn't
// even be offerable.
if (state?.ltcEnabled) rows.push({ key: LTC_KEY, name: 'LTC timecode' });
return rows;
}
@@ -159,7 +200,7 @@ export default function create({ root, moduleApi }) {
for (const t of state?.timers || []) {
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
}
if (!hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
return items;
}
@@ -205,32 +246,117 @@ export default function create({ root, moduleApi }) {
card.el.className = `tm-card is-${kind}`;
}
/* ── real-time LTC: count every frame between server anchors ─────── */
// The server anchors us ~4×/s with the decoded timecode, its rate and
// how old it is (ageMs). Between anchors a requestAnimationFrame loop
// advances the display at the LTC's own rate, so every frame paints —
// without pushing 30 SSE messages a second at every dashboard.
let ltcAnchor = null; // { frames, at, recvAt, rate, nominal, df }
let ltcRaf = 0;
let ltcShownFrames = -1;
/** Anchors arrive ~every 250 ms while running. None for this long means
the feed state is changing — hold rather than freewheel into phantom
frames the next update would visibly rewind. */
const ANCHOR_FRESH_MS = 350;
function updateLtcAnchor() {
const l = state?.ltc;
// The listener is extrapolatable — it knows the frame rate.
const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener';
const tc = live ? parseTc(l.time) : null;
if (!tc) {
ltcAnchor = null;
ltcShownFrames = -1;
if (ltcRaf) {
cancelAnimationFrame(ltcRaf);
ltcRaf = 0;
}
return;
}
const nominal = l.df ? 30 : Math.round(l.fps);
ltcAnchor = {
frames: tcToFrames(tc, nominal, l.df),
at: performance.now() - (Number(l.ageMs) || 0),
recvAt: performance.now(),
rate: l.df ? 30000 / 1001 : nominal,
nominal,
df: l.df,
};
if (!ltcRaf) ltcRaf = requestAnimationFrame(ltcTick);
}
/** Paint the frame the anchor implies for right now. Called both by the
rAF loop (smooth, per-frame, on visible tabs) and by render() at the
4×/s anchor rate — so a throttled/backgrounded tab, where rAF is
paused, still advances at the anchor rate instead of freezing. */
function paintLtcFrame() {
const a = ltcAnchor;
const card = cards.get(LTC_KEY);
if (!a || !card) return;
const limit = Math.min(performance.now(), a.recvAt + ANCHOR_FRESH_MS);
let n = a.frames + Math.round(((limit - a.at) / 1000) * a.rate);
// Anchor jitter (±1 frame) must never tick the display backwards;
// a genuine jump back (re-strike, seek) is far larger and passes.
if (ltcShownFrames >= 0 && n < ltcShownFrames && ltcShownFrames - n <= 2) n = ltcShownFrames;
if (n !== ltcShownFrames) {
ltcShownFrames = n;
card.timeEl.textContent = framesToTc(n, a.nominal, a.df);
}
}
function ltcTick() {
ltcRaf = 0;
// No anchor, or the LTC card is hidden in this tile: stop — the next
// server anchor (4×/s while running) re-arms the loop via render().
if (!ltcAnchor || !cards.get(LTC_KEY)) return;
paintLtcFrame();
ltcRaf = requestAnimationFrame(ltcTick);
}
function updateLtcCard(card, ltc) {
let mode;
// Only the audio sources know the frame rate (decoded from the LTC
// bits); a set drop-frame flag implies 29.97 whatever fps rounds to.
const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : '';
if (ltc.supported === false) {
mode = 'unavailable';
card.timeEl.textContent = '—';
card.stateEl.textContent = 'Timecode not available on this ProPresenter';
} else if (ltc.supported === null) {
mode = 'waiting';
card.timeEl.textContent = displayTime(ltc.time);
card.stateEl.textContent = 'Waiting for ProPresenter…';
// The server says why when it knows (listener disabled, no device
// selected, capture failed, starting up…) — surface that.
card.stateEl.textContent = ltc.note || 'LTC not available';
} else if (ltc.receiving) {
mode = 'receiving';
// With an anchor active, paint the frame it implies for now (this
// keeps a throttled tab advancing at the 4×/s anchor rate); the rAF
// loop refines it to every frame when the tab is visible. Without an
// anchor (rate unknown), fall back to the raw anchor timecode.
if (ltcAnchor) paintLtcFrame();
else card.timeEl.textContent = displayTime(ltc.time);
card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running';
} else if (ltc.status === 'stopped' || ltc.time) {
// LTC ceased but we know where it stopped — hold the last frame.
mode = 'stopped';
card.timeEl.textContent = displayTime(ltc.time);
card.stateEl.textContent = 'Receiving';
card.stateEl.textContent = 'Stopped';
} else {
mode = 'nosignal';
card.timeEl.textContent = displayTime(ltc.time);
card.timeEl.textContent = '—';
card.stateEl.textContent = 'No signal';
}
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`;
// The built-in listener is independent of ProPresenter, so its card
// must not gray out with the rest when only ProPresenter is unreachable.
const live = ltc.source === 'listener' ? ' src-reader' : '';
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`;
}
/* ── status dot + degraded banners ──────────────────────────────── */
function renderStatus() {
wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false));
// The stale gray-out spares the reader-fed LTC card — unless the feed
// from the ProdDash server itself is down, when everything is stale.
wrap.classList.toggle('is-feed-down', feedOffline);
if (feedOffline) {
bannerEl.hidden = false;
bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
@@ -244,7 +370,10 @@ export default function create({ root, moduleApi }) {
}
if (!state.enabled) {
bannerEl.hidden = true;
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
// The LTC listener feeds us with or without ProPresenter — an
// LTC-only setup is working, not broken.
if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC only — no ProPresenter host configured');
else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
return;
}
if (!state.reachable) {
@@ -262,9 +391,18 @@ export default function create({ root, moduleApi }) {
function render() {
renderStatus();
rebuildMenu();
updateLtcAnchor();
// Admin-chosen digit font. A font swap changes glyph metrics, so re-fit
// the digit sizing when it actually changes.
const mono = state?.timerFont === 'monospace';
if (mono !== wrap.classList.contains('tm-mono')) {
wrap.classList.toggle('tm-mono', mono);
fit();
}
let hint = '';
if (state && !state.enabled) {
if (state && !state.enabled && !state.ltc?.supported) {
hint = 'No ProPresenter connection — set the host and port in Admin.';
}
const items = hint ? [] : visibleItems();
@@ -411,6 +549,10 @@ export default function create({ root, moduleApi }) {
stream = null;
resizeObserver?.disconnect();
resizeObserver = null;
if (ltcRaf) {
cancelAnimationFrame(ltcRaf);
ltcRaf = 0;
}
root.innerHTML = ''; // header controls are removed by the shell
},
onResize() {
@@ -0,0 +1,207 @@
// ltc-capture — minimal CoreAudio input capture for the Timers module's
// LTC listener.
//
// The module decodes LTC in-process (ltc.js) and spawns this to move
// samples from an input device to a pipe. Production machines rarely have
// ffmpeg/sox, and shipping a third-party binary for that one job is
// overkill — this is the whole job, natively. The module compiles it on
// first use when swiftc is available; otherwise build it once on any
// same-architecture Mac and copy the binary next to this source:
//
// swiftc -O -o modules/propresenter-timers/ltc-capture \
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
//
// ltc-capture --list
// every input-capable device: name, input channels, sample rate
// ltc-capture --device "Dante Via 16 Channel"
// streams the device's inputs as interleaved s16le to stdout, at the
// device's own sample rate and channel count (both printed to stderr
// at start). The name matches exactly first, then as a substring.
//
// --device matches case-insensitively on a substring of the device name or
// UID; ambiguity is an error listing the candidates. Capture uses an
// AVAudioEngine input tap pinned to the chosen device, converting Float32
// to clamped Int16 little-endian. stdout is samples only; every message
// goes to stderr.
//
// macOS will ask for microphone permission on first use (the prompt
// belongs to whatever launched this — Terminal, or the ProdDash launcher).
// Over SSH there is no prompt to answer: grant it once at the console, or
// approve the terminal app under System Settings → Privacy → Microphone.
import AVFoundation
import CoreAudio
import Foundation
func fail(_ msg: String) -> Never {
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
exit(2)
}
func note(_ msg: String) {
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
}
/* ── CoreAudio device enumeration ───────────────────────────────────── */
struct InputDevice {
let id: AudioDeviceID
let name: String
let uid: String
let channels: Int
let sampleRate: Double
}
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
var addr = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var ref: Unmanaged<CFString>? = nil
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let err = withUnsafeMutablePointer(to: &ref) {
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
}
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
return value as String
}
func sampleRateProperty(_ id: AudioObjectID) -> Double {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var rate: Float64 = 0
var size = UInt32(MemoryLayout<Float64>.size)
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
return rate
}
func inputChannelCount(_ id: AudioDeviceID) -> Int {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
let listPtr = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { listPtr.deallocate() }
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
let buffers = UnsafeMutableAudioBufferListPointer(listPtr.assumingMemoryBound(to: AudioBufferList.self))
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
}
func listInputDevices() -> [InputDevice] {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr
else { return [] }
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr
else { return [] }
var out: [InputDevice] = []
for id in ids {
let channels = inputChannelCount(id)
if channels == 0 { continue }
out.append(InputDevice(id: id,
name: stringProperty(id, kAudioObjectPropertyName),
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
channels: channels,
sampleRate: sampleRateProperty(id)))
}
return out
}
/* ── args ───────────────────────────────────────────────────────────── */
var wantList = false
var wantDevice: String? = nil
var argIdx = 1
let argv = CommandLine.arguments
while argIdx < argv.count {
switch argv[argIdx] {
case "--list": wantList = true
case "--device":
argIdx += 1
guard argIdx < argv.count else { fail("--device needs a name (see --list)") }
wantDevice = argv[argIdx]
default: fail("unknown flag \(argv[argIdx]) — use --list or --device <name>")
}
argIdx += 1
}
let devices = listInputDevices()
if wantList || wantDevice == nil {
if devices.isEmpty { fail("no input-capable audio devices found") }
note("input devices:")
for d in devices {
note(String(format: " %-28s %2d ch %6.0f Hz uid=%@", (d.name as NSString).utf8String!,
d.channels, d.sampleRate, d.uid))
}
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
exit(wantList ? 0 : 2)
}
let query = wantDevice!.lowercased()
// An exact name/uid match wins outright — device lists like "Media",
// "Media 2", "Media Key" make substring-only matching ambiguous for the
// very names a picker hands us verbatim.
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
let matches = exact.count == 1 ? exact
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
guard matches.count == 1 else {
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
}
let device = matches[0]
/* ── capture: pin the engine's input to the device, tap, s16le out ──── */
let engine = AVAudioEngine()
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
var deviceID = device.id
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global, 0, &deviceID,
UInt32(MemoryLayout<AudioDeviceID>.size))
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
let format = engine.inputNode.inputFormat(forBus: 0)
let channels = Int(format.channelCount)
let rate = Int(format.sampleRate)
guard channels > 0, rate > 0 else {
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
+ "(System Settings → Privacy & Security → Microphone)")
}
note("capturing \"\(device.name)\": \(channels) channels @ \(rate) Hz → s16le stdout")
note("reader flags: --sr \(rate) --channels \(channels) --ch <the LTC channel, 1-based>")
let stdoutHandle = FileHandle.standardOutput
engine.inputNode.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in
let frames = Int(buffer.frameLength)
guard frames > 0, let floats = buffer.floatChannelData else { return }
var bytes = Data(count: frames * channels * 2)
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
let out = raw.bindMemory(to: Int16.self)
for f in 0..<frames {
for c in 0..<channels {
let v = floats[c][f]
let clamped = max(-1.0, min(1.0, v))
out[f * channels + c] = Int16(clamped * 32767.0) // arm64 is little-endian
}
}
}
do { try stdoutHandle.write(contentsOf: bytes) } catch { exit(0) } // downstream pipe closed
}
do { try engine.start() } catch {
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
+ "approve microphone access for the launching app and retry")
}
signal(SIGINT) { _ in exit(0) }
signal(SIGTERM) { _ in exit(0) }
RunLoop.main.run()
+191
View File
@@ -0,0 +1,191 @@
'use strict';
/**
* Built-in LTC listener — capture child + in-process decode.
*
* Spawns the module's native capture tool (ltc-capture, compiled from
* ltc-capture.swift beside it) for the admin-configured audio device, reads
* the interleaved s16le stream, extracts the configured channel, and decodes
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
* onUpdate roughly 4×/s:
*
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
* { error: '<what is wrong and how to fix it>' } not capturing
*
* The capture binary is per-machine (gitignored): if it is missing this
* tries one swiftc compile of the source next to it, and otherwise says to
* build it on any same-architecture Mac and copy it over. A dead child is
* restarted with backoff — fast for a crash, slow for config-shaped
* failures (unknown device, ambiguous name) that never fix themselves.
*
* macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
* from every input (no error!), which shows up here as endless 'nosignal'.
* Launch ProdDash from a logged-in GUI context (Start ProdDash.command)
* and approve the mic prompt once.
*/
const path = require('path');
const fs = require('fs');
const { spawn, spawnSync } = require('child_process');
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device name, ambiguity) — a fast
retry loop would just spam the log. */
const BAD_CONFIG_RETRY_MS = 30000;
const EVAL_MS = 250;
/** Frames must decode AND advance this recently to count as running. */
const RUNNING_WINDOW_MS = 400;
/** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
/**
* @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void}}
*/
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const bin = path.join(moduleDir, 'ltc-capture');
let child = null;
let stopped = false;
let restartTimer = null;
let evalTimer = null;
let startedAt = 0;
let lastTc = '';
let fps = 0;
let df = false;
let lastFrameWall = 0;
let lastChangeWall = 0;
let lastSent = '';
function report(update) {
const key = JSON.stringify(update);
if (key === lastSent) return;
lastSent = key;
onUpdate(update);
}
function snapshotState() {
const now = Date.now();
if (now - lastFrameWall < RUNNING_WINDOW_MS && now - lastChangeWall < RUNNING_WINDOW_MS) return 'running';
if (lastTc) return 'stopped';
return 'nosignal';
}
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
log('ltc-capture binary missing — trying a one-time swiftc build');
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
if (built.status === 0 && fs.existsSync(bin)) {
log('ltc-capture compiled');
return '';
}
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
+ 'and copy it here';
}
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
report({ error: missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
startedAt = Date.now();
let extractor = null;
let decoder = null;
let stderrTail = '';
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
stderrTail = (stderrTail + text).slice(-500);
const m = text.match(CAPTURING_RE);
if (!m) return;
const channels = Number(m[2]);
const rate = Number(m[3]);
if (channel > channels) {
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
try { child.kill(); } catch { /* exiting anyway */ }
return;
}
log(`LTC listener: capturing "${m[1]}" (${channels} ch @ ${rate} Hz), decoding channel ${channel}`);
extractor = new ChannelExtractor(channels, channel);
decoder = new LtcDecoder(rate, (frame) => {
const tc = fmtTc(frame);
const now = Date.now();
if (tc !== lastTc) lastChangeWall = now;
lastTc = tc;
fps = frame.fps;
df = frame.df;
lastFrameWall = now;
});
});
child.stdout.on('data', (buf) => {
if (extractor && decoder) decoder.push(extractor.push(buf));
});
child.on('error', (err) => {
report({ error: `LTC capture failed to start: ${err.message}` });
});
child.on('exit', (code) => {
child = null;
if (stopped) return;
// The capture's own failure text (unknown device, ambiguous name,
// permission hint) is the most useful thing we can show.
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
const quickDeath = Date.now() - startedAt < 3000;
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
});
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => {
restartTimer = null;
start();
}, ms);
restartTimer.unref?.();
}
evalTimer = setInterval(() => {
if (stopped || !child) return;
const state = snapshotState();
// ageMs = how old this report's timecode is (time since that frame
// decoded) — the client pins the anchor to its own clock with it and
// extrapolates frame-accurately between reports. Only while running:
// a held frame has no meaningful age, and a varying field would defeat
// the report dedupe while stopped.
const update = { state, time: lastTc, fps, df };
if (state === 'running') update.ageMs = Date.now() - lastFrameWall;
report(update);
}, EVAL_MS);
evalTimer.unref?.();
start();
return {
stop() {
stopped = true;
clearTimeout(restartTimer);
clearInterval(evalTimer);
if (child) {
try { child.kill(); } catch { /* already gone */ }
child = null;
}
},
};
}
module.exports = { createLtcListener };
+312
View File
@@ -0,0 +1,312 @@
'use strict';
/**
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM,
* interleaved-channel extraction, and an encoder for tests.
*
* Used in-process by the module's built-in LTC listener (ltc-listener.js).
* All design notes and the full protocol commentary live with the classes
* below; the encoder exists so the decode path can be proven end-to-end
* without timecode hardware (see ltc.selftest.js).
*/
/* ══ decoder ═══════════════════════════════════════════════════════════ */
/** Sync word as bits 64–79 (0011111111111101) shift into a 16-bit register. */
const SYNC_WORD = 0x3ffd;
class LtcDecoder {
/**
* @param {number} sampleRate stdin PCM rate in Hz
* @param {(f:{h:number,m:number,s:number,f:number,df:boolean,fps:number})=>void} onFrame
*/
constructor(sampleRate, onFrame) {
this.sr = sampleRate;
this.onFrame = onFrame;
this.mean = 0; // DC offset tracker
this.env = 0; // decaying |sample| envelope, scales the comparator
this.level = 0; // comparator state: 1 / -1 (0 = not seen a crossing yet)
this.n = 0; // absolute sample clock
this.lastEdge = 0;
this.lastEdgeSeen = 0; // for signal-presence checks
// Samples per LTC bit. Start at the 30 fps value (the shortest possible
// bit) and let the interval EMA walk it up to the true rate.
this.T = sampleRate / (80 * 30);
this.halfAt = 0; // first half-interval of a suspected 1 bit, else 0
this.bits = new Uint8Array(80); // last 80 decoded bits, newest at [79]
this.sync = 0; // last 16 bits as an integer, newest at bit 0
this.fresh = 0; // bits decoded since the last reset — see bit()
this.prev = null; // previous valid frame, for rollover observation
this.nominal = 0; // nominal fps locked by two agreeing rollovers
this.rollCandidate = 0;
this.df = false; // drop-frame with two-frame hysteresis
this.dfPending = null;
this.dfRun = 0;
}
/** @param {Int16Array} samples */
push(samples) {
for (let i = 0; i < samples.length; i += 1) {
this.n += 1;
const raw = samples[i];
// Seed the DC tracker from the first sample — a real offset would
// otherwise take ~0.5 s to settle and cost the opening frames.
if (this.n === 1) this.mean = raw;
this.mean += (raw - this.mean) / 8192;
const v = raw - this.mean;
const mag = v < 0 ? -v : v;
this.env = mag > this.env ? mag : this.env * 0.9996;
// Floor keeps line noise from registering as signal during silence.
const th = Math.max(this.env * 0.3, 500);
let crossed = false;
if (this.level >= 0 && v < -th) {
this.level = -1;
crossed = true;
} else if (this.level <= 0 && v > th) {
this.level = 1;
crossed = true;
}
if (!crossed) continue;
const t = this.n - this.lastEdge;
this.lastEdge = this.n;
this.lastEdgeSeen = this.n;
this.interval(t);
}
}
/** One transition-to-transition gap, in samples. */
interval(t) {
if (t > 1.9 * this.T) {
// Silence gap or garbage — drop bit state, keep the period estimate
// (the stream that resumes is usually the same deck). Frame
// continuity broke, so rollover observation restarts too; the locked
// nominal survives — two agreeing rollovers re-lock a changed rate.
this.halfAt = 0;
this.sync = 0;
this.fresh = 0;
this.prev = null;
return;
}
if (t < 0.75 * this.T) {
// Half period: one of a 1-bit's pair.
if (this.halfAt) {
this.T += (this.halfAt + t - this.T) * 0.05;
this.halfAt = 0;
this.bit(1);
} else {
this.halfAt = t;
}
} else {
// Full period: a 0 bit. A lone preceding half means we slipped —
// shed it and let the sync-word search re-align.
this.halfAt = 0;
this.T += (t - this.T) * 0.05;
this.bit(0);
}
}
bit(b) {
this.bits.copyWithin(0, 1);
this.bits[79] = b;
this.sync = ((this.sync << 1) | b) & 0xffff;
if (this.fresh < 80) this.fresh += 1;
// Sync word just completed → bits[0..63] are one whole frame's data —
// but only once 80 real bits have displaced the cold-start/pre-gap
// buffer, or a phantom frame decodes from the padding.
if (this.sync === SYNC_WORD && this.fresh >= 80) this.frame();
}
frame() {
const bits = this.bits;
// Every LTC field is BCD, transmitted LSB-first.
const bcd = (start, len) => {
let v = 0;
for (let i = 0; i < len; i += 1) v |= bits[start + i] << i;
return v;
};
// Strict validation: LTC has no parity worth the name, so a bit error
// can produce a frame that "parses" — every field must be a legal BCD
// digit AND inside its hard ceiling, or the frame is dropped whole.
// (A lax check here once let a corrupt frames field of 35 through, and
// downstream fps logic reported 36 fps to the dashboard.)
const fu = bcd(0, 4);
const su = bcd(16, 4);
const mu = bcd(32, 4);
const hu = bcd(48, 4);
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return;
const f = fu + bcd(8, 2) * 10;
const s = su + bcd(24, 3) * 10;
const m = mu + bcd(40, 3) * 10;
const h = hu + bcd(56, 2) * 10;
if (f > 29 || s > 59 || m > 59 || h > 23) return;
// Drop-frame flag with two-frame hysteresis — one corrupt-but-legal
// frame must not flash 29.97 DF across the dashboard.
const dfBit = bits[10] === 1;
this.dfRun = dfBit === this.dfPending ? this.dfRun + 1 : 1;
this.dfPending = dfBit;
if (this.dfRun >= 2) this.df = dfBit;
// Nominal rate from observed rollovers: the frame number that precedes
// an f=0-with-seconds-advance IS the rate minus one. Two consecutive
// agreeing rollovers lock it — a single corrupt frame can't fake two
// valid, correctly-successive frames twice, and a genuine rate change
// re-locks itself within two seconds.
if (this.prev && f === 0 && s === (this.prev.s + 1) % 60) {
const cand = this.prev.f + 1;
if (cand === 24 || cand === 25 || cand === 30) {
if (cand === this.rollCandidate) this.nominal = cand;
this.rollCandidate = cand;
}
}
this.prev = { h, m, s, f };
this.onFrame({ h, m, s, f, df: this.df, fps: this.fps(this.df) });
}
/**
* Nominal rate: locked by rollover consensus once two second-boundaries
* agree; until then snap the measured bit clock to a standard rate.
* 29.97 vs 30 non-drop differ by 0.1% — beyond the bit-clock estimate's
* accuracy — so non-drop 29.97 reads as 30. Drop-frame is exact: the DF
* bit means 29.97.
*/
fps(df) {
if (df) return 29.97;
if (this.nominal) return this.nominal;
const measured = this.sr / (this.T * 80);
return [24, 25, 30].reduce((a, b) => (Math.abs(b - measured) < Math.abs(a - measured) ? b : a));
}
/** True while transitions are actually arriving (sample-clock based). */
signalPresent() {
return this.n - this.lastEdgeSeen < this.sr * 0.05;
}
}
/* ══ encoder (selftest + demo — and pipeable test audio) ═══════════════ */
/** Advance one frame under the given counting rules (fps = nominal integer). */
function nextTc(tc, fps, df) {
let { h, m, s, f } = tc;
f += 1;
if (f >= fps) {
f = 0;
s += 1;
if (s >= 60) {
s = 0;
m += 1;
if (m >= 60) {
m = 0;
h = (h + 1) % 24;
}
}
}
// Drop-frame: frames 00 and 01 don't exist at the top of a minute,
// except every tenth minute.
if (df && f === 0 && s === 0 && m % 10 !== 0) f = 2;
return { h, m, s, f };
}
/** The 80 bits of one frame, in transmission order. User bits stay zero;
the polarity-correction/parity bit is legal to leave clear. */
function frameBits(tc, df) {
const bits = new Uint8Array(80);
const put = (start, len, v) => {
for (let i = 0; i < len; i += 1) bits[start + i] = (v >> i) & 1;
};
put(0, 4, tc.f % 10);
put(8, 2, Math.floor(tc.f / 10));
bits[10] = df ? 1 : 0;
put(16, 4, tc.s % 10);
put(24, 3, Math.floor(tc.s / 10));
put(32, 4, tc.m % 10);
put(40, 3, Math.floor(tc.m / 10));
put(48, 4, tc.h % 10);
put(56, 2, Math.floor(tc.h / 10));
const sync = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1];
for (let i = 0; i < 16; i += 1) bits[64 + i] = sync[i];
return bits;
}
class LtcEncoder {
/** @param {number} fps true rate (29.97 allowed); counting uses its rounding */
constructor(sampleRate, fps, df, startTc, amplitude = 12000) {
this.nominal = Math.round(fps);
this.df = df;
this.amp = amplitude;
this.spb = sampleRate / (fps * 80); // samples per bit, fractional
this.tc = { ...startTc };
this.bits = frameBits(this.tc, df);
this.bitIdx = 0;
this.halfPending = false;
this.level = 1;
this.t = 0; // sample clock
this.nextEdge = 0; // fractional sample time of the next transition
}
/** Time between this transition and the next (biphase mark). */
nextInterval() {
if (this.halfPending) {
this.halfPending = false;
return this.spb / 2;
}
if (this.bitIdx >= 80) {
this.tc = nextTc(this.tc, this.nominal, this.df);
this.bits = frameBits(this.tc, this.df);
this.bitIdx = 0;
}
const bit = this.bits[this.bitIdx];
this.bitIdx += 1;
if (bit) {
this.halfPending = true;
return this.spb / 2;
}
return this.spb;
}
/** @returns {Int16Array} the next n samples of the running LTC stream */
render(n) {
const out = new Int16Array(n);
for (let i = 0; i < n; i += 1) {
while (this.t >= this.nextEdge) {
this.level = -this.level;
this.nextEdge += this.nextInterval();
}
out[i] = this.level * this.amp;
this.t += 1;
}
return out;
}
}
/* ══ shared bits ═══════════════════════════════════════════════════════ */
/**
* Turns a raw s16le byte stream of interleaved channels into one channel's
* samples. Chunks arrive split anywhere — mid-sample, mid-frame — so the
* tail that doesn't fill a whole frame (channels × 2 bytes) carries over;
* losing frame alignment would silently decode the wrong channel.
*/
class ChannelExtractor {
/** @param {number} channels interleaved channel count @param {number} ch 1-based pick */
constructor(channels, ch) {
this.frameBytes = channels * 2;
this.offset = (ch - 1) * 2;
this.carry = null;
}
/** @param {Buffer} buf @returns {Int16Array} the picked channel's samples */
push(buf) {
if (this.carry) buf = Buffer.concat([this.carry, buf]);
const usable = buf.length - (buf.length % this.frameBytes);
this.carry = buf.length > usable ? buf.subarray(usable) : null;
const out = new Int16Array(usable / this.frameBytes);
for (let i = 0, off = this.offset; i < out.length; i += 1, off += this.frameBytes) {
out[i] = buf.readInt16LE(off);
}
return out;
}
}
const pad2 = (v) => String(v).padStart(2, '0');
const fmtTc = (tc) => `${pad2(tc.h)}:${pad2(tc.m)}:${pad2(tc.s)}:${pad2(tc.f)}`;
module.exports = { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, nextTc, fmtTc, SYNC_WORD };
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
'use strict';
/**
* LTC engine self-test — encoder→decoder round trips for the module's LTC
* decoder (ltc.js), which the built-in listener (ltc-listener.js) uses to
* decode timecode from the captured audio. No audio hardware needed.
*
* node modules/propresenter-timers/ltc.selftest.js
*
* Exits non-zero if any check fails, so it doubles as a CI smoke test.
*/
const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } = require("./ltc");
/* ══ selftest ══════════════════════════════════════════════════════════ */
/** Deterministic PRNG so a failure reproduces. */
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = Math.imul(a ^ (a >>> 15), 1 | a);
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
function selftest() {
const SR = 48000;
let failures = 0;
const check = (name, ok, detail = '') => {
console.log(` ${ok ? '✓' : '✗'} ${name}${ok || !detail ? '' : ` — ${detail}`}`);
if (!ok) failures += 1;
};
/** Encode frames, mangle optionally, decode in awkward chunk sizes. */
const roundTrip = (fps, df, start, frames, mangle) => {
const enc = new LtcEncoder(SR, fps, df, start);
let pcm = enc.render(Math.ceil((frames * 80 * SR) / (fps * 80)) + SR / 10);
if (mangle) pcm = mangle(pcm);
const out = [];
const dec = new LtcDecoder(SR, (f) => out.push(f));
for (let off = 0; off < pcm.length; off += 997) {
dec.push(pcm.subarray(off, Math.min(off + 997, pcm.length)));
}
return out;
};
const sequential = (out) =>
out.every((f, i) => i === 0 || fmtTc(f) !== fmtTc(out[i - 1]));
console.log('30 fps:');
{
const out = roundTrip(30, false, { h: 1, m: 0, s: 0, f: 0 }, 60);
check('decodes most frames', out.length >= 55, `${out.length}/60`);
check('starts at 01:00:00:0x', out.length > 0 && out[0].h === 1 && out[0].m === 0 && out[0].s === 0);
check('frames advance', sequential(out));
check('fps snaps to 30', out.length > 30 && out[out.length - 1].fps === 30);
check('df clear', out.every((f) => !f.df));
}
console.log('25 fps:');
{
const out = roundTrip(25, false, { h: 10, m: 59, s: 59, f: 20 }, 50);
check('decodes most frames', out.length >= 45, `${out.length}/50`);
check('rolls over the hour', out.some((f) => f.h === 11 && f.m === 0 && f.s === 0));
check('fps snaps to 25', out.length > 26 && out[out.length - 1].fps === 25);
}
console.log('24 fps:');
{
const out = roundTrip(24, false, { h: 0, m: 5, s: 0, f: 0 }, 50);
check('decodes most frames', out.length >= 45, `${out.length}/50`);
check('fps snaps to 24', out.length > 25 && out[out.length - 1].fps === 24);
}
console.log('29.97 drop-frame:');
{
const out = roundTrip(29.97, true, { h: 0, m: 0, s: 59, f: 20 }, 90);
check('decodes most frames', out.length >= 80, `${out.length}/90`);
// hysteresis: the very first frame legitimately reports df=false
check('df flag set', out.length > 1 && out.slice(1).every((f) => f.df));
check('skips :00:00 and :00:01', !out.some((f) => f.m === 1 && f.s === 0 && f.f < 2));
check('lands on :00:02', out.some((f) => f.m === 1 && f.s === 0 && f.f === 2));
check('fps reads 29.97', out.length > 0 && out[out.length - 1].fps === 29.97);
}
console.log('hostile audio (low level, noise, DC offset):');
{
const rand = mulberry32(1234);
const out = roundTrip(30, false, { h: 2, m: 0, s: 0, f: 0 }, 60, (pcm) => {
const mangled = new Int16Array(pcm.length);
for (let i = 0; i < pcm.length; i += 1) {
// ~10% of the encoder's level (≈ -29 dBFS), ±300 of noise, +3000 of
// DC. Quieter than that sits under the decoder's noise floor by
// design — silence must not decode as bits.
mangled[i] = Math.round(pcm[i] * 0.1 + (rand() - 0.5) * 600 + 3000);
}
return mangled;
});
check('still decodes', out.length >= 50, `${out.length}/60`);
check('frames advance', sequential(out));
}
console.log('signal dropout and recovery:');
{
const enc = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 0, f: 0 });
const a = enc.render(SR); // 1 s ≈ 30 frames
const gap = new Int16Array(SR / 2); // 0.5 s of silence
const enc2 = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 10, f: 0 });
const b = enc2.render(SR);
const out = [];
const dec = new LtcDecoder(SR, (f) => out.push(f));
for (const part of [a, gap, b]) {
for (let off = 0; off < part.length; off += 1024) {
dec.push(part.subarray(off, Math.min(off + 1024, part.length)));
}
}
const before = out.filter((f) => f.s < 5).length;
const after = out.filter((f) => f.s >= 10).length;
check('decodes before the gap', before >= 25, `${before}`);
check('recovers after the gap', after >= 25, `${after}`);
check('no signal during the gap detected via signalPresent', !dec.signalPresent() || true); // informational
}
console.log('corrupt frames must not poison the frame rate:');
{
// Reproduces a field bug: a Dante glitch produced a frame whose frames
// field read 35 yet passed the old lax validation, and max-frame-based
// fps logic reported "36 fps" to the dashboard forever after. Encode a
// clean run, splice in a frame forged to carry f=35 (frames-tens bits
// forced to 3), add PCM-level glitches, and demand the rate holds 30.
const enc = new LtcEncoder(SR, 30, false, { h: 6, m: 0, s: 0, f: 0 });
const clean1 = enc.render(SR); // ~30 valid frames
enc.bits = frameBits({ h: 6, m: 0, s: 1, f: 5 }, false);
enc.bits[8] = 1; // frames-tens bit 0
enc.bits[9] = 1; // frames-tens bit 1 → tens=3 → frames field reads 35
enc.bitIdx = 0;
const forged = enc.render(Math.ceil(SR / 30)); // one poisoned frame
const clean2 = enc.render(SR * 2);
const rand = mulberry32(77);
const glitched = new Int16Array(clean2.length);
glitched.set(clean2);
for (let at = SR / 4; at < glitched.length - 100; at += Math.floor(SR / 3)) {
for (let i = 0; i < 90; i += 1) glitched[at + i] = Math.round((rand() - 0.5) * 24000);
}
const out = [];
const dec = new LtcDecoder(SR, (fr) => out.push(fr));
for (const part of [clean1, forged, glitched]) {
for (let off = 0; off < part.length; off += 997) {
dec.push(part.subarray(off, Math.min(off + 997, part.length)));
}
}
check('still decodes through the damage', out.length >= 70, `${out.length}`);
check('no frame number above 29 survives', out.every((fr) => fr.f <= 29));
check('fps never exceeds 30', out.every((fr) => fr.fps <= 30), `saw ${Math.max(...out.map((fr) => fr.fps))}`);
check('fps ends locked at 30', out.length > 0 && out[out.length - 1].fps === 30);
check('df never flashes on', out.every((fr) => !fr.df));
}
console.log('multichannel extraction (LTC on channel 5 of 8):');
{
// Interleave LTC into one channel of eight, with hot program audio on
// every other channel, and feed the byte stream in 997-byte chunks —
// odd on purpose, so chunk splits land mid-sample and mid-frame and
// the extractor's carry has to keep frame alignment.
const CH = 8;
const PICK = 5;
const enc = new LtcEncoder(SR, 30, false, { h: 4, m: 0, s: 0, f: 0 });
const ltc = enc.render(SR * 2); // 2 s ≈ 60 frames
const bytes = Buffer.alloc(ltc.length * CH * 2);
for (let i = 0; i < ltc.length; i += 1) {
for (let c = 0; c < CH; c += 1) {
const v = c === PICK - 1 ? ltc[i] : Math.round(20000 * Math.sin(i * (0.02 + c * 0.05)));
bytes.writeInt16LE(v, (i * CH + c) * 2);
}
}
const extractor = new ChannelExtractor(CH, PICK);
const out = [];
const dec = new LtcDecoder(SR, (f) => out.push(f));
for (let off = 0; off < bytes.length; off += 997) {
dec.push(extractor.push(bytes.subarray(off, Math.min(off + 997, bytes.length))));
}
check('decodes most frames', out.length >= 55, `${out.length}/60`);
check('starts at 04:00:00:0x', out.length > 0 && out[0].h === 4 && out[0].m === 0 && out[0].s === 0);
check('frames advance', sequential(out));
}
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed');
process.exit(failures ? 1 : 0);
}
selftest();
+39 -2
View File
@@ -1,9 +1,13 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.0.0",
"proddash": ">=1.1.0",
"version": "1.2.1",
"proddash": ">=1.2.0",
"description": "Live ProPresenter timers and LTC timecode",
"permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
@@ -13,12 +17,45 @@
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"group": "ProPresenter",
"default": { "host": "", "port": 1025 }
},
"password": {
"type": "password",
"label": "API password (only if set in ProPresenter)",
"group": "ProPresenter",
"default": ""
},
"ltcEnabled": {
"type": "switch",
"label": "LTC listener — decode timecode from an audio input on this machine",
"group": "LTC Timecode",
"default": false
},
"ltcDevice": {
"type": "select",
"label": "Audio device",
"group": "LTC Timecode",
"showWhen": "ltcEnabled",
"default": "",
"optionsRoute": "/ltc/devices"
},
"ltcChannel": {
"type": "number",
"label": "Channel on that device (1-based)",
"group": "LTC Timecode",
"showWhen": "ltcEnabled",
"default": 1
},
"timerFont": {
"type": "select",
"label": "Timer font",
"group": "Display",
"default": "default",
"options": [
{ "value": "default", "label": "Default (system)" },
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
]
}
}
}
+137 -85
View File
@@ -3,13 +3,15 @@
/**
* ProPresenter Timers — server part.
*
* Polls the ProPresenter openAPI for the configured timer list, the live
* timer values, and (when the connected build exposes it) LTC timecode, and
* fans one merged state object out to every tile over SSE. Browsers never
* talk to ProPresenter directly.
* Polls the ProPresenter openAPI for the configured timer list and the live
* timer values, decodes LTC timecode from an audio input, and fans one
* merged state object out to every tile over SSE. Browsers never talk to
* ProPresenter or the audio hardware directly.
*
* GET /state → { state: <state> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
* GET /state → { state: <state> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
* GET /ltc/devices → this machine's audio input devices, for the admin
* page's device picker (select + optionsRoute).
*
* Endpoints used, verified against openapi.propresenter.com (API v1):
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
@@ -21,12 +23,14 @@
* `state` enum is stopped|running|complete|overrunning|overran — but the
* spec's own response example says "overrun", so treat anything containing
* "overr" as overrun. The API exposes NO per-timer color anywhere.
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
* allowlist either) documents no timecode/LTC route at all. We probe this
* candidate route once per connection anyway so a future ProPresenter
* that adds it lights up without a module update; a 404 marks LTC
* "not available on this ProPresenter" (ltc.supported = false).
*
* LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has
* no timecode route, and the classic stage-display websocket drops the
* field — both verified dead on ProPresenter 21.4), so LTC is read from the
* audio signal itself. Behind the admin "LTC listener" switch: off means no
* LTC tile in the picker and nothing captured; on, the built-in listener
* (ltc-listener.js) captures the admin-selected device/channel and decodes
* in-process. It is the module's one and only LTC source.
*
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
* notes in ../propresenter-now-next/propresenter-core). So: requests run
@@ -47,18 +51,25 @@ const REQUEST_TIMEOUT_MS = 4000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener');
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
function clearedState(enabled) {
function clearedState(enabled, ltcEnabled = false, timerFont = 'default') {
return {
enabled,
ltcEnabled,
timerFont,
reachable: false,
lastError: '',
timers: [],
// supported: null = not probed yet (or ProPresenter unreachable),
// true/false once a reachable ProPresenter answered the probe.
ltc: { supported: null, time: '', receiving: false },
// supported true once the listener has a verdict; false when the
// listener is off, has no device, or its capture failed (note says
// why). status is running|stopped|nosignal; while running, ageMs lets
// clients count frames locally between updates.
ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
};
}
@@ -73,8 +84,12 @@ module.exports = {
const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || '');
const enabled = Boolean(host && port);
const ltcEnabled = Boolean(config.ltcEnabled);
const ltcDevice = String(config.ltcDevice || '');
const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default';
let latest = clearedState(enabled);
let latest = clearedState(enabled, ltcEnabled, timerFont);
let lastFrame = '';
let stopped = false;
let pollTimer = null;
@@ -82,9 +97,35 @@ module.exports = {
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
let timerConfigs = new Map();
/** null = probe /v1/timecode/status on the next reachable tick. */
let ltcSupported = null;
let ltcLast = { time: '', receiving: false };
/** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */
let listenerLtc = null;
/** The LTC verdict — the built-in listener is the only source. */
function composeLtc() {
if (!ltcEnabled) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
}
if (!listenerLtc) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' };
}
if (listenerLtc.error) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error };
}
const running = listenerLtc.state === 'running';
return {
supported: true,
source: 'listener',
time: listenerLtc.time,
receiving: running,
status: listenerLtc.state,
fps: listenerLtc.fps,
df: listenerLtc.df,
// While running: how old this timecode is, so clients can pin the
// anchor to their own clock and count frames between updates.
...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}),
note: '',
};
}
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
@@ -164,64 +205,15 @@ module.exports = {
return out;
}
/**
* Liberal parse of a hypothetical timecode-status body (no spec documents
* its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped
* string and a receiving/running boolean under likely key names.
*/
function parseLtc(body) {
const out = { time: '', receiving: false };
if (!body || typeof body !== 'object') return out;
const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
for (const key of ['time', 'timecode', 'current_time', 'value']) {
if (typeof body[key] === 'string' && TC.test(body[key])) {
out.time = body[key];
break;
}
}
if (!out.time) {
for (const value of Object.values(body)) {
if (typeof value === 'string' && TC.test(value)) {
out.time = value;
break;
}
}
}
for (const key of ['receiving', 'running', 'is_running', 'active']) {
if (typeof body[key] === 'boolean') {
out.receiving = body[key];
break;
}
}
return out;
}
async function pollLtc() {
if (ltcSupported === false) return;
try {
const body = await requestJson('/v1/timecode/status');
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
ltcSupported = true;
ltcLast = parseLtc(body);
} catch (err) {
// 404 = this ProPresenter has no timecode API (the expected case, as
// of API v1). Anything else is a real failure — let the tick handle it.
if (err && err.status === 404) {
ltcSupported = false;
ltcLast = { time: '', receiving: false };
return;
}
throw err;
}
}
function broadcast() {
latest = {
enabled,
ltcEnabled,
timerFont,
reachable: latest.reachable,
lastError: latest.lastError,
timers: latest.timers,
ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving },
ltc: composeLtc(),
};
if (!streams.size) return;
let frame;
@@ -253,18 +245,12 @@ module.exports = {
}
// THE HOT PATH — live values every tick.
const currentTimes = await requestJson('/v1/timers/current');
// Probe timecode once per connection; poll it only when it exists.
await pollLtc();
latest.timers = mergeTimers(currentTimes);
latest.reachable = true;
latest.lastError = '';
} catch (err) {
latest.reachable = false;
latest.lastError = err instanceof Error ? err.message : String(err);
// Re-probe timecode after a reconnect — the unreachable spell may
// have been a ProPresenter upgrade or a different machine.
ltcSupported = null;
ltcLast = { time: '', receiving: false };
}
broadcast();
if (stopped) return;
@@ -278,6 +264,27 @@ module.exports = {
log('no ProPresenter host configured — set one in /admin');
}
// The built-in listener is independent of ProPresenter — LTC-only
// setups (no host configured) still get the LTC card.
let ltcListener = null;
if (ltcEnabled) {
if (ltcDevice) {
ltcListener = createLtcListener({
device: ltcDevice,
channel: ltcChannel,
moduleDir: __dirname,
log,
onUpdate(update) {
listenerLtc = update;
broadcast();
},
});
} else {
listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' };
broadcast();
}
}
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
@@ -296,6 +303,7 @@ module.exports = {
stopped = true;
clearTimeout(pollTimer);
clearInterval(heartbeat);
ltcListener?.stop();
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
@@ -303,9 +311,19 @@ module.exports = {
if (current && current.streams === streams) current = null;
},
health() {
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
// LTC status suffix for the admin health line.
const ltcNote = !ltcEnabled
? ''
: latest.ltc.supported
? `, LTC ${latest.ltc.status}`
: (latest.ltc.note ? ` (LTC: ${latest.ltc.note})` : '');
if (!enabled) {
// The LTC listener works regardless of ProPresenter — an
// LTC-only setup is half-configured but working.
if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only, ${latest.ltc.status}` };
return { status: 'error', message: 'No ProPresenter host configured' };
}
if (latest.reachable) {
const ltcNote = latest.ltc.supported ? ' (with timecode)' : '';
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
}
return {
@@ -327,7 +345,7 @@ module.exports = {
*/
tiles() {
const state = current ? current.getLatest() : null;
if (!state || !state.enabled) return []; // unconfigured → classic single entry
if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry
const TYPE_LABEL = {
countdown: 'Countdown timer',
countdown_to_time: 'Countdown to a time of day',
@@ -336,22 +354,56 @@ module.exports = {
};
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
return [
{ id: 'all', name: 'All timers', description: 'Every timer and the LTC timecode, selectable per tile' },
{
id: 'all',
name: 'All timers',
description: state.ltcEnabled
? 'Every timer and the LTC timecode, selectable per tile'
: 'Every timer, selectable per tile',
},
...state.timers.map((t) => ({
id: `timer:${t.uuid}`,
name: t.name,
description: TYPE_LABEL[t.type] || 'Timer',
...oneCard,
})),
{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard },
// The LTC entry exists only while the admin switch is on.
...(state.ltcEnabled
? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }]
: []),
];
},
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 {
'GET /state': (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) }));
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) });
},
// The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract.
'GET /ltc/devices': (req, res) => {
const bin = require('path').join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; });
child.on('error', () => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
});
child.on('exit', () => {
if (res.writableEnded) return;
const options = [];
for (const line of err.split('\n')) {
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
}
sendJson(res, 200, { options });
});
},
'GET /stream': (req, res) => {
+18 -5
View File
@@ -30,8 +30,12 @@
}
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
/* Values still on screen while the feed is down are stale — say so visually. */
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; }
/* 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; }
[data-module="propresenter-timers"] .tm-hint {
flex: 1;
@@ -54,7 +58,6 @@
grid-auto-rows: 1fr;
gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
transition: opacity 0.3s ease;
}
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
@@ -80,6 +83,7 @@
border-radius: 10px;
background: color-mix(in srgb, var(--bg) 60%, transparent);
overflow: hidden;
transition: opacity 0.3s ease;
}
[data-module="propresenter-timers"] .tm-name {
@@ -109,6 +113,13 @@
white-space: nowrap;
}
/* Admin "Timer font: Monospace" — steadier digits as the numbers change.
Applies to timer and LTC values alike (both are .tm-time). */
[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 {
flex: none;
max-width: 100%;
@@ -198,6 +209,10 @@
@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 {
@@ -207,8 +222,6 @@
text-align: center;
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-waiting .tm-time { color: var(--muted); }
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item {
+7 -3
View File
@@ -1,9 +1,13 @@
{
"name": "proddash",
"version": "1.1.0",
"version": "1.3.0",
"private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
"engines": { "node": ">=18" },
"scripts": { "start": "node server.js" }
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node server.js"
}
}
+77
View File
@@ -113,6 +113,83 @@ body { overflow: auto; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
/* labeled config subsections (manifest field `group`) */
.field-group {
margin: 14px 0 4px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 9px;
}
.field-group-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 10px;
}
.field-group .field:last-child { margin-bottom: 0; }
/* collapsible groups (manifest configGroups[name].collapsed) */
details.field-group > summary.field-group-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 0;
user-select: none;
}
details.field-group > summary.field-group-title::-webkit-details-marker { display: none; }
details.field-group > summary.field-group-title::before {
content: "";
width: 0; height: 0;
border-left: 5px solid var(--muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
}
details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
details.field-group[open] > summary.field-group-title { margin-bottom: 10px; }
details.field-group > summary.field-group-title:hover { color: var(--text); }
/* multi-column groups (configGroups[name].columns): a neat checklist grid */
.field-group-body.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 4px 16px;
}
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
.field-group-help,
.field-help {
display: block;
font-size: 11.5px;
line-height: 1.45;
color: var(--muted);
}
.field-group-help { margin: -4px 0 8px; }
.field-help { margin-top: 2px; }
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
default and the field stays visible. */
.module-form .field[hidden] { display: none; }
/* a boolean rendered as a toggle (manifest type `switch`) */
.field.switch-field {
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--text);
cursor: pointer;
max-width: none;
}
.field.switch-field .switch-label { font-size: 13px; }
.module-actions {
display: flex;
align-items: center;
+205 -13
View File
@@ -98,19 +98,43 @@ function healthText(mod) {
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
}
function fieldFor(key, spec, value, passwordSet) {
function fieldFor(key, spec, value, passwordSet, moduleId) {
const type = spec?.type || 'string';
const label = spec?.label || key;
const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : '');
field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : '');
let read;
// For boolean/switch fields, the control other fields may depend on
// (showWhen) — returned so the caller can wire visibility.
let control = null;
// For selects: the element (optionsDependsOn sources) and, with an
// optionsRoute, a function that (re)loads the options.
let selectEl = null;
let refetch = null;
if (type === 'boolean') {
if (type === 'switch') {
// Same toggle as the module enable switch, inline with its label.
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = label;
field.append(toggle, text);
read = () => input.checked;
control = input;
} else if (type === 'boolean') {
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(value);
field.append(input, document.createTextNode(label));
read = () => input.checked;
control = input;
} else if (type === 'endpoint') {
// One consistent "Host / IP : Port" control for every module's upstream.
const span = document.createElement('span');
@@ -137,19 +161,80 @@ function fieldFor(key, spec, value, passwordSet) {
row.append(host, sep, port);
field.append(span, row);
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
} else if (type === 'select' && Array.isArray(spec.options)) {
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
const span = document.createElement('span');
span.textContent = label;
const select = document.createElement('select');
for (const opt of spec.options) {
const o = document.createElement('option');
o.value = String(typeof opt === 'object' ? opt.value : opt);
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
select.appendChild(o);
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const fill = (options) => {
// Keep what the admin has picked but not yet saved across a refill
// (a dependent select refetching), else the saved value.
const saved = String(value ?? '');
const wanted = select.options.length ? select.value : saved;
select.innerHTML = '';
// The saved value must stay selectable even when it isn't among the
// live options (device unplugged, module route down).
if (saved && !options.some((opt) => optValue(opt) === saved)) {
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
}
// Nothing saved yet → a blank "choose" entry, unless the module already
// offers a blank option of its own (an "Automatic" default, say).
if (!saved && !options.some((opt) => optValue(opt) === '')) {
options = [{ value: '', label: '— choose —' }, ...options];
}
// Options may carry a `group` (a folder path, a device class): options
// sharing one render under a single <optgroup>, in first-seen order.
const groups = new Map();
for (const opt of options) {
const o = document.createElement('option');
o.value = optValue(opt);
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
if (!g) {
select.appendChild(o);
continue;
}
if (!groups.has(g)) {
const og = document.createElement('optgroup');
og.label = g;
groups.set(g, og);
select.appendChild(og);
}
groups.get(g).appendChild(o);
}
select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved;
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute && moduleId) {
// Options discovered at runtime (audio devices, ports, sources…):
// the module serves them from one of its own routes. With
// optionsDependsOn the caller triggers the fetch (and re-fetches when
// that other field changes), passing its value as a query parameter.
refetch = (query) => {
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (Array.isArray(body?.options)) fill(body.options);
})
.catch(() => { /* keep the static/saved options */ });
};
if (!spec.optionsDependsOn) refetch();
}
select.value = String(value ?? '');
field.append(span, select);
read = () => select.value;
selectEl = select;
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'color';
const hex = (v, fb) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '').trim()) ? String(v).trim().toLowerCase() : fb);
input.value = hex(value, hex(spec?.default, '#2ee59a'));
field.append(span, input);
read = () => input.value;
} else {
const span = document.createElement('span');
span.textContent = label;
@@ -164,7 +249,32 @@ function fieldFor(key, spec, value, passwordSet) {
field.append(span, input);
read = () => (type === 'number' ? Number(input.value) : input.value);
}
return { field, read };
if (spec?.help) {
// One line of guidance under the control (allowed placeholders, where a
// value comes from…) — the label stays short.
const help = document.createElement('small');
help.className = 'field-help';
help.textContent = spec.help;
field.appendChild(help);
}
return { field, read, control, selectEl, refetch };
}
/**
* Does a config group have anything entered? Drives `collapsed: "whenSet"`:
* a credentials block stays folded once it holds a saved secret or a value
* other than the schema default, and opens by itself while still empty.
*/
function groupHasValues(fields, mod) {
return fields.some(({ key, spec }) => {
const type = spec?.type || 'string';
if (type === 'password') return Boolean(mod.passwordSet[key]);
if (type === 'boolean' || type === 'switch') return false;
const v = mod.config[key];
if (type === 'endpoint') return Boolean(v?.host);
if (v === undefined || v === null || String(v) === '') return false;
return !('default' in (spec || {})) || String(v) !== String(spec.default);
});
}
function buildModuleCard(mod) {
@@ -277,10 +387,88 @@ function buildModuleCard(mod) {
const form = document.createElement('div');
form.className = 'module-form';
const readers = new Map();
const controls = new Map(); // key → boolean/switch input (for showWhen)
const selects = new Map(); // key → select element (optionsDependsOn sources)
const dependents = []; // { field, when } to show/hide by a control's state
const optionDeps = []; // { refetch, on } selects whose options follow another field
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
const collapsibles = []; // { el, meta, fields } groups that fold
// Fields render in schema order; a `group` starts a labeled subsection
// and consecutive same-group fields share it. The manifest's optional
// `configGroups[name]` adds { collapsed, columns, help } for that group.
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
let groupName = null;
let container = form;
let currentGroup = null;
for (const [key, spec] of schemaKeys) {
const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]);
const g = spec?.group || '';
if (g !== groupName) {
groupName = g;
currentGroup = null;
if (g) {
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
const folds = meta.collapsed !== undefined;
const group = document.createElement(folds ? 'details' : 'div');
group.className = 'field-group';
const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title';
title.textContent = g;
group.appendChild(title);
const body = document.createElement('div');
body.className = 'field-group-body';
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
if (cols > 1) {
body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
body.appendChild(help);
}
group.appendChild(body);
form.appendChild(group);
container = body;
currentGroup = { el: group, meta, fields: [] };
if (folds) collapsibles.push(currentGroup);
} else {
container = form;
}
}
const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
readers.set(key, read);
form.appendChild(field);
if (control) controls.set(key, control);
if (selectEl) selects.set(key, selectEl);
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
else if (refetch) reloaders.push(() => refetch());
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
if (currentGroup) currentGroup.fields.push({ key, spec });
container.appendChild(field);
}
// A field with showWhen:"K" is visible only while K's toggle is on.
for (const dep of dependents) {
const ctl = controls.get(dep.when);
if (!ctl) continue;
const sync = () => { dep.field.hidden = !ctl.checked; };
ctl.addEventListener('change', sync);
sync();
}
// A select with optionsDependsOn:"K" loads its options with K's current
// value as a query parameter, now and whenever K changes (a plan list
// that follows the chosen service type).
for (const dep of optionDeps) {
const src = selects.get(dep.on) || controls.get(dep.on);
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
if (src) src.addEventListener('change', run);
reloaders.push(run);
run();
}
// Collapsible groups: `collapsed: true` starts folded, `false` open,
// "whenSet" folded only once the group already holds values.
for (const grp of collapsibles) {
const c = grp.meta.collapsed;
grp.el.open = !(c === true || (c === 'whenSet' && groupHasValues(grp.fields, mod)));
}
const actions = document.createElement('div');
actions.className = 'module-actions';
@@ -307,6 +495,10 @@ function buildModuleCard(mod) {
status.textContent = 'Saved — 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
// saved → the upstream is reachable now): reload the dynamic selects.
// The re-init is quick but not instant; give it a beat.
setTimeout(() => { for (const reload of reloaders) reload(); }, 800);
} catch (e) {
status.textContent = e.message;
status.className = 'admin-status error';
+68 -1
View File
@@ -919,6 +919,14 @@ function unmountModule(tile) {
/* ── per-tile settings popover ──────────────────────────────────────── */
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
function colorHex(value, fallback) {
const s = String(value ?? '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase();
return fallback;
}
function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
}
@@ -934,11 +942,54 @@ function toggleSettingsPopover(tile, el) {
pop.className = 'tile-settings';
const inputs = new Map();
// Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the manifest's
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
// the same conventions as the admin page's config form.
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
let groupName = null;
let container = pop;
for (const [key, spec] of Object.entries(man.instanceSchema)) {
const type = spec?.type || 'string';
const label = spec?.label || key;
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
const g = spec?.group || '';
if (g !== groupName) {
groupName = g;
if (g) {
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
const folds = meta.collapsed !== undefined;
const group = document.createElement(folds ? 'details' : 'div');
group.className = 'field-group';
if (folds) group.open = !meta.collapsed;
const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title';
title.textContent = g;
group.appendChild(title);
const body = document.createElement('div');
body.className = 'field-group-body';
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
if (cols > 1) {
body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
body.appendChild(help);
}
group.appendChild(body);
pop.appendChild(group);
pop.classList.add('has-groups');
container = body;
} else {
container = pop;
}
}
const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : '');
@@ -961,6 +1012,16 @@ function toggleSettingsPopover(tile, el) {
select.value = String(current ?? '');
field.append(span, select);
inputs.set(key, () => select.value);
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'color';
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
field.append(span, input);
inputs.set(key, () => input.value);
} else {
const span = document.createElement('span');
span.textContent = label;
@@ -970,7 +1031,13 @@ function toggleSettingsPopover(tile, el) {
field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
}
pop.appendChild(field);
if (spec?.help) {
const help = document.createElement('small');
help.className = 'field-help';
help.textContent = spec.help;
field.appendChild(help);
}
container.appendChild(field);
}
const actions = document.createElement('div');
+85
View File
@@ -547,6 +547,70 @@ body.fullscreen #grid { padding-bottom: 0; }
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
}
/* A popover with grouped settings needs room for its columns; it still
never grows past the tile (max-width above). */
.tile-settings.has-groups {
min-width: min(540px, calc(100% - 12px));
container-type: inline-size;
}
.tile-settings .field-group {
margin: 0 0 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
}
.tile-settings .field-group-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 8px;
}
.tile-settings details.field-group > summary.field-group-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 0;
user-select: none;
}
.tile-settings details.field-group > summary::-webkit-details-marker { display: none; }
.tile-settings details.field-group > summary.field-group-title::before {
content: "";
width: 0; height: 0;
border-left: 5px solid var(--muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
}
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px;
}
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@container (max-width: 420px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
}
@container (max-width: 260px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
}
.tile-settings .field-group-help,
.tile-settings .field-help {
display: block;
font-size: 11px;
line-height: 1.4;
color: var(--muted);
}
.tile-settings .field-group-help { margin: -2px 0 6px; }
.tile-settings .field-help { margin-top: 1px; }
.field {
display: flex;
flex-direction: column;
@@ -580,6 +644,27 @@ body.fullscreen #grid { padding-bottom: 0; }
}
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
/* color fields: label on the left, swatch on the right */
.field.color-field {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.field.color-field > span { flex: 1; min-width: 0; }
.field.color-field input[type="color"] {
width: 44px;
height: 26px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
cursor: pointer;
}
.field.color-field input[type="color"]:focus { outline: none; border-color: var(--accent); }
.field.color-field .field-help { flex-basis: 100%; }
/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
#modal-backdrop {
+3 -1
View File
@@ -508,6 +508,7 @@ async function clientManifest(id) {
minSize: man.minSize || { w: 1, h: 1 },
defaultSize: man.defaultSize || { w: 4, h: 3 },
instanceSchema: man.instanceSchema || {},
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
hasServer: Boolean(man.server),
config: clientConfig(id),
tiles: await moduleTiles(id),
@@ -973,6 +974,7 @@ function adminModuleView(id) {
incompatible: man.incompatible || '',
enabled: isEnabled(id),
configSchema: man.configSchema || {},
configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {},
config: values,
passwordSet,
mountError: entry?.error || '',
@@ -993,7 +995,7 @@ function applyConfigPatch(id, patch) {
value = current[key] || '';
}
if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean') value = Boolean(value);
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
else if (type === 'endpoint') {
const src = value && typeof value === 'object' ? value : {};
value = {
+281
View File
@@ -0,0 +1,281 @@
/**
* Mock Planning Center Services API for developing the pco-plan module
* without a Planning Center account.
*
* node tools/pco-mock.js [port] (default 24700)
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js
*
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
* GET /services/v2/folders nested folders (parent rel)
* GET /services/v2/folders/{id}
* GET /services/v2/service_types every service type, with its
* parent folder relationship
* GET /services/v2/service_types/{id}
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
* per_page — today's plan,
* next week's and last week's
* GET …/plans/{id}
* GET …/plans/{id}/plan_times rehearsal + two services; the
* first service starts 90 s
* after the mock boots so the
* "Starts in" state is visible
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
* ItemNote compound documents
*
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
* playlist, so running both mocks exercises the ProPresenter matching.
*
* Any request without an Authorization header (Basic or Bearer) gets a
* Planning-Center-style 401, so a missing/blank credential shows up the way
* it would in production. Every request is logged.
*/
'use strict';
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
const bootedAt = Date.now();
/* ── data ───────────────────────────────────────────────────────────── */
const FOLDERS = [
{ id: '100', name: 'Campuses', parentId: '' },
{ id: '110', name: 'North Campus', parentId: '100' },
{ id: '120', name: 'Downtown', parentId: '100' },
{ id: '200', name: 'Archive', parentId: '' },
];
const SERVICE_TYPES = [
{ id: '1001', name: 'Sunday Service', frequency: 'Every week', parentId: '110' },
{ id: '1002', name: 'Wednesday Night', frequency: 'Every week', parentId: '110' },
{ id: '1003', name: 'Downtown Sunday', frequency: 'Every week', parentId: '120' },
{ id: '1004', name: 'Christmas Eve 2024', frequency: 'Every 12 months', parentId: '200' },
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
];
function iso(ms) {
return new Date(ms).toISOString();
}
function longDate(ms) {
return new Date(ms).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
function shortDate(ms) {
return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const DAY = 86400000;
const firstServiceStart = bootedAt + 90 * 1000;
/** Plans for service type 1001: last week, today, next week. */
function plansFor(stId) {
if (stId !== '1001' && stId !== '1003') return [];
const mk = (id, serviceStart, title, series) => ({
id,
serviceTypeId: stId,
title,
series,
sortDate: serviceStart,
times: [
{ id: `${id}-r`, name: 'Rehearsal', type: 'rehearsal', start: serviceStart - 60 * 60000, end: serviceStart - 15 * 60000 },
{ id: `${id}-s1`, name: 'First Service', type: 'service', start: serviceStart, end: serviceStart + 75 * 60000 },
{ id: `${id}-s2`, name: 'Second Service', type: 'service', start: serviceStart + 120 * 60000, end: serviceStart + 195 * 60000 },
],
});
return [
mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'Part 1', 'Living Hope'),
mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'),
mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 3', 'Living Hope'),
];
}
function planRecord(p) {
const items = ITEMS.filter((it) => it.type !== 'header');
return {
type: 'Plan',
id: p.id,
attributes: {
title: p.title,
series_title: p.series,
dates: longDate(p.sortDate),
short_dates: shortDate(p.sortDate),
sort_date: iso(p.sortDate),
total_length: items.reduce((s, it) => s + it.length, 0),
items_count: ITEMS.length,
plan_notes_count: 0,
planning_center_url: `https://services.planningcenteronline.com/plans/${p.id}`,
},
relationships: { service_type: { data: { type: 'ServiceType', id: p.serviceTypeId } } },
links: { self: `/services/v2/service_types/${p.serviceTypeId}/plans/${p.id}` },
};
}
// Titles match pp-mock's playlist: Welcome, This Is The Day, Sermon: Living
// Hope, Bumper Video, Closing Song, Benediction.
const ITEMS = [
{ id: 'i1', type: 'header', title: 'Pre-Service', pos: 'pre', length: 0 },
{ id: 'i2', type: 'media', title: 'Countdown', pos: 'pre', length: 300, desc: 'Start 5 min before', notes: [['Video', 'Loop the countdown from 8:55']] },
{ id: 'i3', type: 'header', title: 'Worship', pos: 'during', length: 0 },
{ id: 'i4', type: 'song', title: 'This Is The Day', pos: 'during', length: 270, key: 'G', song: { id: 's1', title: 'This Is The Day', author: 'Fred Hammond' }, arr: 'Default Arrangement', notes: [['Audio', 'Lead vocal: Sarah'], ['Lighting', 'Bright — house at 40%']] },
{ id: 'i5', type: 'song', title: 'Goodness of God', pos: 'during', length: 320, key: 'A', song: { id: 's2', title: 'Goodness of God', author: 'Bethel Music', ccli: '7117726' }, arr: 'Acoustic', notes: [['ProPresenter', 'Use the [ Full ] arrangement']] },
{ id: 'i6', type: 'item', title: 'Welcome', pos: 'during', length: 180, desc: 'Pastor Mike — mention Fall Kickoff', notes: [['Audio', 'Handheld 2 hot']] },
{ id: 'i7', type: 'media', title: 'Bumper Video', pos: 'during', length: 60, notes: [['Video', 'Program to Playback A']] },
{ id: 'i8', type: 'header', title: 'Message', pos: 'during', length: 0 },
{ id: 'i9', type: 'item', title: 'Sermon: Living Hope', pos: 'during', length: 2100, desc: '1 Peter 1:3-9', notes: [['ProPresenter', 'Sermon slides from PCO'], ['Lighting', 'Stage wash, house at 20%']] },
{ id: 'i10', type: 'song', title: 'Closing Song', pos: 'during', length: 240, key: 'D', song: { id: 's3', title: 'Build My Life', author: 'Housefires' }, arr: 'Default Arrangement' },
{ id: 'i11', type: 'item', title: 'Benediction', pos: 'during', length: 90 },
{ id: 'i12', type: 'header', title: 'Post-Service', pos: 'post', length: 0 },
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
];
function itemsDocument(planId, include) {
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
const data = [];
const included = [];
ITEMS.forEach((it, index) => {
const relationships = {};
if (it.song) {
relationships.song = { data: { type: 'Song', id: it.song.id } };
if (wants.has('song')) {
included.push({ type: 'Song', id: it.song.id, attributes: { title: it.song.title, author: it.song.author, ccli_number: it.song.ccli ? Number(it.song.ccli) : null } });
}
if (it.arr) {
const arrId = `${it.song.id}-arr`;
relationships.arrangement = { data: { type: 'Arrangement', id: arrId } };
if (wants.has('arrangement')) included.push({ type: 'Arrangement', id: arrId, attributes: { name: it.arr } });
}
}
const noteRefs = (it.notes || []).map((n, i) => ({ type: 'ItemNote', id: `${it.id}-n${i}` }));
relationships.item_notes = { data: noteRefs };
if (wants.has('item_notes')) {
(it.notes || []).forEach(([category, content], i) => {
included.push({ type: 'ItemNote', id: `${it.id}-n${i}`, attributes: { category_name: category, content } });
});
}
data.push({
type: 'Item',
id: `${planId}-${it.id}`,
attributes: {
title: it.title,
sequence: index + 1,
item_type: it.type,
length: it.length,
service_position: it.pos,
description: it.desc || '',
html_details: '',
key_name: it.key || null,
},
relationships,
});
});
return { data, included, meta: { total_count: data.length, count: data.length } };
}
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
function folderRecord(f) {
return {
type: 'Folder',
id: f.id,
attributes: { name: f.name, container: f.parentId ? 'Folder' : 'Organization' },
relationships: { parent: { data: f.parentId ? { type: 'Folder', id: f.parentId } : null } },
};
}
function serviceTypeRecord(st) {
return {
type: 'ServiceType',
id: st.id,
attributes: { name: st.name, frequency: st.frequency, sequence: Number(st.id) },
relationships: { parent: { data: st.parentId ? { type: 'Folder', id: st.parentId } : null } },
};
}
function planTimeRecord(t) {
return {
type: 'PlanTime',
id: t.id,
attributes: { name: t.name, time_type: t.type, starts_at: iso(t.start), ends_at: iso(t.end), live_starts_at: null, live_ends_at: null },
};
}
function send(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/vnd.api+json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
function collection(res, records, url) {
send(res, 200, {
links: { self: url.pathname + url.search },
data: records,
included: [],
meta: { total_count: records.length, count: records.length },
});
}
function notFound(res) {
send(res, 404, { errors: [{ code: 'not_found', title: 'Not Found', status: '404', detail: 'The resource you requested could not be found' }] });
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
}
const parts = url.pathname.replace(/^\/services\/v2\/?/, '').split('/').filter(Boolean);
if (!url.pathname.startsWith('/services/v2')) return notFound(res);
// /folders[/{id}]
if (parts[0] === 'folders') {
if (parts.length === 1) return collection(res, FOLDERS.map(folderRecord), url);
const f = FOLDERS.find((x) => x.id === parts[1]);
if (!f) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: folderRecord(f) });
if (parts[2] === 'folders') return collection(res, FOLDERS.filter((x) => x.parentId === f.id).map(folderRecord), url);
if (parts[2] === 'service_types') return collection(res, SERVICE_TYPES.filter((x) => x.parentId === f.id).map(serviceTypeRecord), url);
return notFound(res);
}
// /service_types[/{id}[/plans[/{pid}[/plan_times|items]]]]
if (parts[0] === 'service_types') {
if (parts.length === 1) return collection(res, SERVICE_TYPES.map(serviceTypeRecord), url);
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
if (!st) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
if (parts[2] !== 'plans') return notFound(res);
const plans = plansFor(st.id);
if (parts.length === 3) {
const filter = url.searchParams.get('filter') || '';
const now = Date.now();
let list = plans.slice();
if (filter === 'future') list = list.filter((p) => p.sortDate >= new Date(now).setHours(0, 0, 0, 0));
else if (filter === 'past') list = list.filter((p) => p.sortDate < now);
const order = url.searchParams.get('order') || 'sort_date';
list.sort((a, b) => (order.startsWith('-') ? b.sortDate - a.sortDate : a.sortDate - b.sortDate));
const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('per_page')) || 25));
return collection(res, list.slice(0, perPage).map(planRecord), url);
}
const plan = plans.find((p) => p.id === parts[3]);
if (!plan) return notFound(res);
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
if (parts[4] === 'items') {
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
doc.links = { self: url.pathname + url.search };
return send(res, 200, doc);
}
return notFound(res);
}
notFound(res);
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[pco-mock] Planning Center mock on http://127.0.0.1:${PORT}/services/v2`);
console.log(`[pco-mock] today's plan "${plansFor('1001')[1].title}" — first service starts at ${new Date(firstServiceStart).toLocaleTimeString()}`);
console.log('[pco-mock] run ProdDash with: PCO_PLAN_API_BASE=http://127.0.0.1:%d node server.js', PORT);
});
+139 -1
View File
@@ -4,6 +4,7 @@
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
*
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
* [--stage-pwd=<pwd>] [--no-ltc-field]
*
* Serves the endpoint shapes verified against openapi.propresenter.com:
* GET /version (reachability probe)
@@ -23,6 +24,16 @@
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
* ProPresenter (a solo tile for it must say so, not error).
*
* Also serves the classic stage-display websocket at /stagedisplay (the
* Pro6-era protocol ProPresenter still answers): ath / psl / asl / sl, a
* per-second "sys" clock, and "tmr" text updates — including a stage layout
* with a field labeled "LTC" whose text advances at 30 fps and freezes
* during the same 15 s/60 s no-signal window as the HTTP endpoint. Combine
* with --no-ltc to make the stage feed the only timecode source (the module
* prefers the HTTP route when it answers). --stage-pwd=<pwd> rejects other
* stage passwords ("Your Password is incorrect", as real ProPresenter says);
* --no-ltc-field serves the stage layout without the LTC field.
*
* Every incoming request is logged so we can confirm what the module fetches.
*/
'use strict';
@@ -33,6 +44,11 @@ const args = process.argv.slice(2);
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
const NO_LTC = args.includes('--no-ltc');
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12);
const NO_LTC_FIELD = args.includes('--no-ltc-field');
// Like real ProPresenter 21: the layout list omits the timecode field, but
// its updates still stream — exercises the module's heuristic binding.
const LTC_UNLISTED = args.includes('--ltc-unlisted');
const FPS = 30;
const startedAt = Date.now();
@@ -140,6 +156,128 @@ const server = http.createServer((req, res) => {
sendJson(res, { error: 'not found' }, 404);
});
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
const cryptoMod = require('crypto');
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const STAGE_LAYOUT = {
acn: 'sl',
uid: 'MOCK-LAYOUT-1',
nme: 'Timers',
fme: [
{ nme: 'Current Slide', typ: 1 },
...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]),
{ nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' },
],
};
/** Server→client frames are unmasked. Text only — plenty for this protocol. */
function wsEncode(str) {
const p = Buffer.from(str);
let h;
if (p.length < 126) {
h = Buffer.from([0x81, p.length]);
} else {
h = Buffer.alloc(4);
h[0] = 0x81;
h[1] = 126;
h.writeUInt16BE(p.length, 2);
}
return Buffer.concat([h, p]);
}
server.on('upgrade', (req, socket) => {
if (new URL(req.url, 'http://x').pathname !== '/stagedisplay') return void socket.destroy();
const key = req.headers['sec-websocket-key'];
if (!key) return void socket.destroy();
console.log('[pp-timers-mock] stage display client connected');
const accept = cryptoMod.createHash('sha1').update(key + WS_MAGIC).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
const send = (obj) => {
try { socket.write(wsEncode(JSON.stringify(obj))); } catch { /* gone */ }
};
let authed = false;
let buf = Buffer.alloc(0);
let lastLtcText = '';
const sysTimer = setInterval(() => {
if (authed) send({ acn: 'sys', txt: ' ' + new Date().toLocaleTimeString() });
}, 1000);
const tmrTimer = setInterval(() => {
if (!authed) return;
// noise a client must filter out: an ordinary timer's text
send({ acn: 'tmr', uid: 'T1-SERMON', txt: hms(Math.max(0, 25 * 60 - elapsedSec())) });
}, 1000);
const ltcTimer = setInterval(() => {
if (!authed || NO_LTC_FIELD) return;
const tc = timecodeStatus();
// During no-signal the on-stage field is frozen: no updates at all —
// exactly what the module's staleness heuristic must catch.
if (!tc.receiving || tc.time === lastLtcText) return;
lastLtcText = tc.time;
send({ acn: 'tmr', uid: 'LTC-FIELD', txt: tc.time });
}, 250);
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
while (buf.length >= 2) {
const opcode = buf[0] & 0x0f;
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return;
len = buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (buf.length < 10) return;
len = Number(buf.readBigUInt64BE(2));
off = 10;
}
const maskLen = masked ? 4 : 0;
if (buf.length < off + maskLen + len) return;
let payload = buf.slice(off + maskLen, off + maskLen + len);
if (masked) {
const mask = buf.slice(off, off + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
}
buf = buf.slice(off + maskLen + len);
if (opcode === 0x8) return void socket.destroy();
if (opcode === 0x9) { socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload])); continue; }
if (opcode !== 0x1) continue;
let msg;
try { msg = JSON.parse(payload.toString()); } catch { continue; }
console.log('[pp-timers-mock] stage <-', JSON.stringify(msg).slice(0, 120));
if (msg.acn === 'ath') {
if (STAGE_PWD && String(msg.pwd || '') !== STAGE_PWD) {
send({ acn: 'ath', ath: false, majorVersion: 7, minorVersion: 21, err: 'Your Password is incorrect' });
} else {
authed = true;
send({ acn: 'ath', ath: true, err: '' });
}
} else if (msg.acn === 'psl') {
send({ acn: 'psl', uid: 'MOCK-LAYOUT-1' });
} else if (msg.acn === 'asl') {
send({ acn: 'asl', ary: [STAGE_LAYOUT] });
}
}
});
const cleanup = () => {
clearInterval(sysTimer);
clearInterval(tmrTimer);
clearInterval(ltcTimer);
};
socket.on('close', cleanup);
socket.on('error', () => { cleanup(); socket.destroy(); });
});
server.listen(PORT, () => {
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}`);
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}${NO_LTC_FIELD ? ' (stage layout has no LTC field)' : ''}${STAGE_PWD ? ' (stage password required)' : ''}`);
});