Compare commits

..
Author SHA1 Message Date
Michael McGary fb233ca8d7 Merge pull request #15 from michaelmcgary2008/claude/dashboard-round-2026-09
Dashboard round: themes, admin ⓘ and group toggles, switches, add-tile fix, Timers overhaul, PCO service timing
2026-09-15 15:24:44 -04:00
Waters ChurchandClaude Fable 5.1 f0fbf8a8f6 Timers: no "Other modules" catch-all in admin
The Timers admin had a fourth group, "Other modules", whose one switch gated
every timer provider that wasn't PCO Plan. A provider that earns a switch
earns a section of its own — so the catch-all goes, any other provider found
is consumed as soon as it appears, and its name (not "Other modules") is what
the health readout lists it under.

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

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

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

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

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

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

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

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

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

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

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

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

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

Three changes, all in the shell:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:40:46 -04:00
48 changed files with 7767 additions and 1119 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "start",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start"
],
"port": 3000
}
]
}
+4
View File
@@ -10,3 +10,7 @@ config/layouts/
# compiled per-machine, source sits next to it # compiled per-machine, source sits next to it
modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/
launcher/macos/.cache/
+9 -5
View File
@@ -3,11 +3,15 @@
Modular production dashboard web app for Waters Church, developed in this repo. Modular production dashboard web app for Waters Church, developed in this repo.
The app lives at the repo root and is built: zero-dependency Node server The app lives at the repo root and is built: zero-dependency Node server
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
`/admin`, modules in `modules/`. Runtime state (module config, named layouts) `/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
is written to a per-machine data directory outside the checkout `launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never the server and its own Node runtime, supervises them, and owns the macOS
add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the permissions modules declare; see its README). Runtime state (module
initial build; [README.md](README.md) has run/configure/use instructions. config, named layouts) is written to a per-machine data directory outside the
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
kept") — never add code that writes runtime state into the repo folder.
[SPEC.md](SPEC.md) was the authority for the initial build;
[README.md](README.md) has run/configure/use instructions.
## Building a new module — read docs/MODULE-GUIDE.md first ## Building a new module — read docs/MODULE-GUIDE.md first
+46 -3
View File
@@ -29,6 +29,27 @@ and a clear degraded state, then recover on their own.
## Run it ## Run it
### macOS — the app
Open the disk image and drag **ProdDash** to Applications. That's the whole
installation: the app carries the dashboard and its own Node runtime, so
nothing else has to be installed and nothing can be missing. It lives in the
menu bar, starts and stops the server, can open at login, and holds the
permissions the modules need — the microphone the LTC listener reads, and
local network access for ProdCom and ProPresenter. macOS grants those to the
app that owns the process, so a server started any other way (over SSH, from
a launchd job) simply doesn't have them, and an LTC input goes silently to
zeroes.
Build the disk image from a checkout with
[launcher/macos](launcher/macos)/`build.sh`:
```bash
cd launcher/macos && ./build.sh --dmg
```
### Anywhere else, or from a checkout
Requires [Node.js](https://nodejs.org) 18 or newer. Requires [Node.js](https://nodejs.org) 18 or newer.
```bash ```bash
@@ -67,14 +88,18 @@ it at startup (`Settings : …`).
The directory holds `modules.json` (module config), `layouts/` (named The directory holds `modules.json` (module config), `layouts/` (named
layouts) and `modules/` (modules installed from the admin page), all written layouts) and `modules/` (modules installed from the admin page), all written
by the app itself. It may also hold a `proddash.json` by the app itself. Installed from the macOS app, it also holds `app/` — the
copy of ProdDash that actually runs, laid down from inside the app bundle on
first launch and updated in place from then on (nothing may write inside a
signed .app, and ProdDash updates itself). It may also hold a `proddash.json`
with this machine's shell settings — the same keys as the checked-in with this machine's shell settings — the same keys as the checked-in
[config/proddash.json](config/proddash.json), which supplies the defaults: [config/proddash.json](config/proddash.json), which supplies the defaults:
| Key | Meaning | | Key | Meaning |
| --- | --- | | --- | --- |
| `port` | Port ProdDash serves on (default `24500`) | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
@@ -87,6 +112,22 @@ so and falls back to the app's `config/` folder.
directory, so nothing has to be re-entered. The old files are left in place and directory, so nothing has to be re-entered. The old files are left in place and
ignored from then on. ignored from then on.
### Themes
The whole dashboard — every tile, on every browser — draws with one palette,
chosen in the admin page's **Theme** section and switched live: pick a swatch
and open dashboards change without a reload. Five presets ship: **Booth**
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
keeps the status colours apart — the accent is never near the danger red or
the warning amber — and muted text readable in a dark booth. The choice is
stored as `theme` in the data directory's `proddash.json` (table above), so
it holds across restarts and updates; a browser remembers the last theme it
saw and applies it before the server answers, so nothing flashes. Modules
style with the shell's colour variables, which is why they follow; a colour
set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
## Installing modules and updating ## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub Everything below happens in the **admin page**; ProdDash talks to its GitHub
@@ -168,8 +209,10 @@ below. Nothing is installed without someone clicking.
- **ProdCom** — live transcript stream with in-place updates of in-progress - **ProdCom** — live transcript stream with in-place updates of in-progress
speech, channel colors, per-tile channel visibility toggles and group speech, channel colors, per-tile channel visibility toggles and group
presets, timestamps, text size and jump-to-latest in the title bar. presets, timestamps, text size, a top-down / bottom-up direction toggle
and jump-to-latest in the title bar.
Admin config: ProdCom server (host : port), API key. Admin config: ProdCom server (host : port), API key.
Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
+139 -12
View File
@@ -66,6 +66,9 @@ the app); its settings are kept for a later reinstall.
"version": "1.0.0", "version": "1.0.0",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "One line shown in the Add-tile picker and the admin page", "description": "One line shown in the Add-tile picker and the admin page",
"permissions": [
{ "kind": "microphone", "reason": "Why this module needs it, in one line" }
],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
@@ -88,12 +91,14 @@ the app); its settings are kept for a later reinstall.
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. | | `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
| `description` | no | Shown in the admin page / picker. | | `description` | no | Shown in the admin page / picker. |
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
| `client` | yes | Client entry file, loaded as an ES module. | | `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. | | `style` | no | A stylesheet the shell injects once per module. |
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | | `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | | `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). | | `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. | | `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
Both schemas map field names to specs: Both schemas map field names to specs:
@@ -111,8 +116,13 @@ Both schemas map field names to specs:
} }
``` ```
`boolean` renders as a checkbox; `switch` is the same true/false value shown `boolean` and `switch` are the same true/false value. In a tile's gear popover
as a toggle (use it for a feature on/off that other fields depend on). both render as a switch; in the admin page `switch` is the toggle and
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
fields depend on). **Label a switch with the noun of what it turns on** —
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
shown or enabled, off means hidden or disabled, and the label shouldn't say
so twice.
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex `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`). default; the tile applies it as a CSS custom property — see `pco-plan`).
@@ -137,7 +147,15 @@ the group name:
still empty. Omit it and the group is a plain, always-open block. 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 - `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
long checklist of booleans compact. long checklist of booleans compact.
- `help`: a line of guidance at the top of the group. - `help`: guidance for the whole group. The admin page shows it as an
ⓘ beside the group title (click or hover to read), so a group can carry a
paragraph without pushing its fields down the page. Say where a value comes
from, not what a field is.
- `toggle`: the key of a `switch` field in this group. It renders in the
group's title row, at the right, as the group's on/off; the rest of the group
dims while it is off, and the field is not repeated in the body. This is how
an integration is disabled without unmounting the module — ProPresenter
timers, say, on a Sunday when ProPresenter isn't running.
`help` on a field puts one line of guidance under its control — allowed `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 placeholders, where a value comes from. Keep labels short and put the
@@ -207,6 +225,38 @@ Admin config lives in `modules.json` inside the server's data directory
survives updates of the app folder). Per-tile settings live inside each survives updates of the app folder). Per-tile settings live inside each
browser's layout. browser's layout.
### Permissions macOS has to grant
Some things a module does need the operating system's permission, not just
configuration: reading an audio input, reaching gear on the local network.
macOS grants those to the **application that owns the process**, which for
ProdDash is the macOS launcher (`launcher/macos`) — the server is its child,
so the prompts are asked, and remembered, in ProdDash's name. A server
started over SSH or from a bare launchd job has no such owner, and the
permission is silently absent rather than refused: an LTC input reads as
endless zeroes, a local host simply never answers.
Declare what you need, and why, so the launcher's Permissions tab can list
it and ask for it before a service rather than during one:
```json
"permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
]
```
| `kind` | When to declare it |
| --- | --- |
| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). |
| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
`reason` is one line, shown to the operator as-is when they hover the
permission; modules giving the same reason are listed together. A bare `"permissions": ["microphone"]` works when
the standing explanation is enough. A `kind` the launcher doesn't manage is
still listed, so nothing goes unmentioned — the shell itself ignores the key
entirely, and other platforms ignore it too.
## Client entry (`client.js`) ## Client entry (`client.js`)
An ES module whose **default export is a factory**. The shell calls it once An ES module whose **default export is a factory**. The shell calls it once
@@ -305,10 +355,13 @@ own in `stop()` anyway.
- **Any number of instances.** Two transcript tiles with different filters - **Any number of instances.** Two transcript tiles with different filters
is a feature. Keep all state inside `create()`'s closure — no module-level is a feature. Keep all state inside `create()`'s closure — no module-level
mutable state in `client.js`. mutable state in `client.js`.
- **Remounts are routine.** When the user edits the tile's gear settings the - **Remounts are routine.** Gear settings apply as the user changes them —
shell calls `stop()` and starts a fresh instance. When admin config there is no Apply button — so each change calls `stop()` and starts a fresh
changes, the shell calls your `onConfigChange(cfg)` if you export one, instance (typing is debounced, and an unchanged value remounts nothing).
otherwise it remounts you. Either way you must come back cleanly. Starting cheaply matters: hold connections and timers in the closure and
tear them down in `stop()`. When admin config changes, the shell calls your
`onConfigChange(cfg)` if you export one, otherwise it remounts you. Either
way you must come back cleanly.
- **The tile can be removed at any moment.** `stop()` is your only notice. - **The tile can be removed at any moment.** `stop()` is your only notice.
### Rules for the client ### Rules for the client
@@ -324,9 +377,15 @@ own in `stop()` anyway.
2. **Style with the shell's CSS variables — no hardcoded colors.** The 2. **Style with the shell's CSS variables — no hardcoded colors.** The
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with `--muted`, `--accent`, `--accent-text` (text that sits on the accent —
dark on most themes, white where the accent is deep), `--danger`,
`--warn`. Derive tints with
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
every module look native on the dark, high-contrast booth theme. every module look native — and what makes the dashboard's **themes**
(Admin → Theme) reach into every tile: a theme swaps those variables and
nothing else. A colour a tile is configured with (a `color` instance
setting, applied as a custom property on the tile) is set closer than the
theme and wins over it, by design.
3. **No external network calls from the client.** Browsers talk only to 3. **No external network calls from the client.** Browsers talk only to
ProdDash. Anything upstream (your device, service, API) goes through your ProdDash. Anything upstream (your device, service, API) goes through your
@@ -346,7 +405,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
```js ```js
module.exports = { module.exports = {
init({ config, log }) { init({ config, log, shell }) {
// Called on startup / re-init, BEFORE routes(). Start your upstream // Called on startup / re-init, BEFORE routes(). Start your upstream
// connection here. Return a handle: // connection here. Return a handle:
return { return {
@@ -355,7 +414,7 @@ module.exports = {
}; };
}, },
routes({ config, log }) { routes({ config, log, shell }) {
// Route table, mounted under /api/modules/<id>/ // Route table, mounted under /api/modules/<id>/
return { return {
'GET /state': (req, res) => { … }, 'GET /state': (req, res) => { … },
@@ -371,6 +430,9 @@ module.exports = {
- `config` is your admin config: `configSchema` defaults overlaid with what - `config` is your admin config: `configSchema` defaults overlaid with what
the admin page saved — **including password fields**. the admin page saved — **including password fields**.
- `log(...)` prefixes output with your module id. - `log(...)` prefixes output with your module id.
- `shell.port` is the port ProdDash is serving on — what you need to read
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
modules/<id>/…`). Never guess it.
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
the matched remainder (leading `/` included) and `req.search` is the query the matched remainder (leading `/` included) and `req.search` is the query
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
@@ -457,6 +519,71 @@ per-client `res.write` in try/catch (drop the client on failure). The
`propresenter-now-next` module is the reference implementation of this `propresenter-now-next` module is the reference implementation of this
pattern. pattern.
## Working with other modules
Modules don't import each other. When one needs what another knows, it reads
that module's **own server routes over loopback**, exactly as a browser would:
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
rather than talking to ProPresenter a second time. A consumer must survive the
provider being disabled, uninstalled, or restarting mid-service (404s and
dropped connections are normal; retry with backoff and say so in `health()`).
### Offering timers to the Timers module
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
clock, and any module that offers some. A module offers timers by:
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
forwards `provides` in `/api/modules`, which is how the Timers module finds
providers without a list anyone maintains.
2. Serving `GET /timers` from its server routes, returning a snapshot:
```json
{
"source": "pco-plan",
"label": "PCO Plan",
"updatedAt": 1757800000000,
"timers": [
{
"id": "service",
"label": "Service",
"kind": "elapsed",
"state": "running",
"startedAt": 1757799000000,
"elapsedMs": 1000000,
"remainingMs": null,
"targetMs": 4500000,
"status": { "text": "Welcome & Announcements", "tone": "ok" },
"detail": "Item 3 of 12"
}
]
}
```
| Field | Meaning |
| --- | --- |
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
| `detail` | Optional second line. |
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
must be cheap to answer (serve from state you already hold; never call
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
(`event: timers`, `data:` the same object, on every change plus a heartbeat
comment every 15 s); the consumer uses the stream when it exists and polls
`/timers` otherwise. Only offer timers a production tech would want on a
wall — the Timers module lets them hide any of them per tile, but every entry
you add is a decision someone has to make.
## Complete minimal example ## Complete minimal example
A working clock — copy-paste this as your starter. (A slightly fancier A working clock — copy-paste this as your starter. (A slightly fancier
+180
View File
@@ -0,0 +1,180 @@
# ProdDash for macOS
The Mac application: a menu-bar app that carries the dashboard, its own Node
runtime, and the permissions its modules need. Installing it is dragging
**ProdDash** to Applications — nothing else has to be present on the machine,
and nothing can go missing from under it.
Build it with `./build.sh`. The app is native Swift with no dependencies of
its own; the Node it ships is fetched from nodejs.org at build time and
checksum-verified.
## What's in the bundle
```
ProdDash.app/Contents/
MacOS/ProdDash the launcher (Swift, ~1 MB)
Resources/node/bin/node the Node runtime ProdDash runs on
Resources/app/ server.js, public/, modules/ — ProdDash itself
```
Everything mutable lives outside, in `~/Library/Application Support/ProdDash`:
module config, layouts, modules installed from the admin page — and `app/`,
the copy of ProdDash that actually runs.
**Why a copy, when the bundle already has one.** ProdDash updates itself: the
admin page's **Update now** downloads a new version and writes it over the app
folder. Nothing may write inside a signed `.app` without breaking its
signature, so the bundle carries a read-only baseline and the data directory
carries the copy that runs. Both routes to a new version work, and they don't
fight: installing a newer `.app` replaces the copy, while a copy that has
already updated itself past the bundle is left alone.
## Why it exists
**Permissions.** macOS grants privacy permissions to the *application* that
owns a process, and a child inherits its parent's identity. Started from here,
node — and the Timers module's `ltc-capture` — belong to this app bundle, so
the microphone and local-network prompts are asked, and remembered, as
ProdDash. Started over SSH or from a bare launchd job there is nobody to ask:
the permission is silently absent rather than refused, which is the "endless
zeroes, no error" failure `ltc-listener.js` warns about.
**Supervision.** The shell was built for this: with `PRODDASH_LAUNCHER=1` it
exits **75** instead of respawning itself when the admin page applies an
update, which the launcher reads as "start me again" rather than as a fault.
A crash gets a backed-off restart (1s, 2s, 5s, 10s); four in two minutes stops
the cycle rather than spinning forever.
## Build
```bash
./build.sh # build/ProdDash.app — the whole application
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
./build.sh --install # …and copy it to /Applications
./build.sh --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
./build.sh --dev # launcher only: no Node, no server — runs against
# the checkout. Seconds, for work on the launcher.
```
Needs the Xcode command line tools (`xcode-select --install`) and macOS 13 or
newer. The first build downloads Node (~50 MB) into `.cache/` and reuses it.
A full build takes about ten seconds and produces a 117 MB app, 43 MB
compressed in the disk image.
Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
`ARCH` and `NODE_VERSION` at the top of `build.sh` — the download is verified
against nodejs.org's published checksums either way.
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
is the app's icon. A module's native helper is compiled during the build
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
ProdDash never needs a compiler.
## Signing
`build.sh` uses a **Developer ID Application** certificate when one is
installed, and signs ad-hoc when none is — which is fine on your own Macs, but
another Mac will refuse the app until someone approves it under System
Settings → Privacy & Security.
To sign properly you need the Apple Developer Program (paid) and a Developer
ID certificate — note this is not the same as the "Apple Development"
certificate Xcode creates for you:
1. Xcode → Settings → Accounts → your team → **Manage Certificates** →
**+** → **Developer ID Application**. (Requires Account Holder or Admin
on the team.) `security find-identity -v -p codesigning` should then
list a `Developer ID Application: …` identity.
2. Store notarisation credentials once, with an app-specific password from
[appleid.apple.com](https://appleid.apple.com):
```bash
xcrun notarytool store-credentials proddash --apple-id you@example.com --team-id TEAMID
```
3. `./build.sh --notarize`
Then the disk image opens by double-click on any Mac, with no warnings.
`CODESIGN_IDENTITY` overrides the certificate; `NOTARY_PROFILE` overrides the
keychain profile name.
## Using it
The menu-bar icon is ProdDash's 2×2 tile mark; the accent tile is lit green
while the server runs, amber mid-move, red when something needs a person, and
hollow when it is stopped. Its menu has the whole app in it:
- **Open Dashboard** / **Open Admin** — the running server, in a browser.
- **Start / Stop / Restart ProdDash**.
- **Launcher…** — the window below.
- **Open at Login** — a login item, through the modern `SMAppService` API.
- **Quit ProdDash** — stops the server too, and asks first while it is
running.
The window has the status, both addresses the dashboard answers on, and three
tabs:
- **Log** — everything the server prints, plus what the launcher did and why.
Also written to `~/Library/Logs/ProdDash/proddash-<date>.log`, kept a week.
- **Settings** — the port, and how it starts (at login, server on open,
restart on crash, open a browser, show this window). It also shows which
copy of ProdDash is running, which node, and where settings are kept.
- **Permissions** — one line per permission: a status dot, its name, and the
one thing you can do about it. See below.
### Port
The port belongs to ProdDash, not to the launcher, so it is written to
`proddash.json` in the data directory — the file the server already reads.
Set it here and ProdDash keeps that port however it is started next. Changing
it while the server runs restarts it.
### Permissions
What appears is what the installed modules declare in their manifests
(`"permissions": [ … ]` — see
[docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which
module asked, and why.
- **Microphone** — asked for directly, with the status macOS reports.
- **Local Network** — macOS has no API that reports whether this was granted,
so **Check Access** asks the network instead: it browses for Bonjour
services (which is what raises the prompt) and tries every upstream the
admin page is pointed at. Nothing answering, when the gear is on, means
macOS is withholding access.
- **Notifications** — the launcher's own, so a server that stops while nobody
is watching still reaches someone. Turn them off for ProdDash in System
Settings if you'd rather not have them.
On first run the launcher asks for what the modules declare, while someone is
still at the keyboard, rather than halfway through a service.
## Details worth knowing
- **One instance.** Opening a second copy raises the first one's window.
- **Port already in use.** If something already holds the port, the launcher
asks before doing anything. When that something answers like ProdDash — an
SSH-started copy, say, with no microphone access — it offers to take the
port over.
- **Stopping is thorough.** The server is spawned into its own process group,
so stopping it also stops anything it started. An orphaned `ltc-capture`
would otherwise hold an audio device until someone found it in Activity
Monitor.
- **Signals.** `killall ProdDash`, a logout or a shutdown stops the server
first rather than leaving node running.
- **Nothing is required outside the app.** A development build (`--dev`)
carries no server and no Node, and runs the checkout it was built in with
whatever node is on the machine — Settings → ProdDash folder points it
somewhere else.
## Troubleshooting
| What you see | What it means |
| --- | --- |
| "ProdDash can't be opened because Apple cannot check it" | The app is ad-hoc signed. System Settings → Privacy & Security → **Open Anyway**, once. Signing it properly (above) removes this. |
| "ProdDash stopped 4 times in two minutes" | The server is failing on startup — the Log tab has its own error. |
| LTC shows no signal | Permissions tab → Microphone. If it says granted and the input is still silent, the server may be an older copy started outside the app — Stop, then Start. |
| Nothing on the network answers | Permissions tab → **Check Access**. |
| A development build says it can't find ProdDash | `--dev` builds need a checkout: Settings → ProdDash folder. |
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>ProdDash</string>
<key>CFBundleDisplayName</key>
<string>ProdDash</string>
<key>CFBundleExecutable</key>
<string>ProdDash</string>
<key>CFBundleIdentifier</key>
<string>org.waterschurch.proddash</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>ProdDashLauncherVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<!-- Menu-bar app: no Dock icon, no app menu. -->
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<!-- The prompts this app exists to own. macOS shows these sentences
verbatim, so they say what ProdDash does with each permission. -->
<key>NSMicrophoneUsageDescription</key>
<string>ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at.</string>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
</array>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The launcher asks for microphone access on the modules' behalf; under
the hardened runtime that needs saying out loud. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Module helpers that read an audio input — ltc-capture, and anything
like it a later module ships. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- V8 compiles JavaScript as it runs; without these the hardened runtime
kills node on its first optimisation. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- A module may ship a native addon that nobody signed with our identity. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+335
View File
@@ -0,0 +1,335 @@
import AppKit
import Combine
/**
The launcher itself: one status-bar item, one window, one server.
It is an accessory app (no Dock icon, no app menu), so everything it can do
is reachable from the menu-bar menu — including quitting, which stops the
server with it.
*/
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
/// The launcher's own version. The app's CFBundleShortVersionString is
/// ProdDash's, because the app *is* ProdDash now.
static var version: String {
Bundle.main.infoDictionary?["ProdDashLauncherVersion"] as? String ?? "dev"
}
/// Sent by a second copy of the launcher so the first one shows itself.
static let showNotification = Notification.Name("org.waterschurch.proddash.launcher.show")
let settings = LauncherSettings()
let permissions = PermissionsModel()
lazy var server = ServerController(settings: settings)
private var statusItem: NSStatusItem?
private var windowController: LauncherWindowController?
private var cancellables = Set<AnyCancellable>()
private var appearanceObserver: NSKeyValueObservation?
private var signalSources: [DispatchSourceSignal] = []
// MARK: - Lifecycle
func applicationDidFinishLaunching(_ notification: Notification) {
guard !handOffToRunningInstance() else { return }
LogStore.shared.launcher("ProdDash Launcher \(Self.version)")
setUpStatusItem()
server.onPortBusy = { [weak self] port, isProdDash, reply in
self?.askAboutBusyPort(port, isProdDash: isProdDash, reply: reply)
}
// Any change in the server's state re-draws the icon; the menu rebuilds
// itself when it opens.
server.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
DistributedNotificationCenter.default().addObserver(
self, selector: #selector(showWindow), name: Self.showNotification, object: nil)
catchShutdownSignals()
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
updateStatusItem()
let firstRun = !settings.hasRunBefore
settings.hasRunBefore = true
if settings.showWindowAtLaunch || firstRun { showWindow() }
if settings.startServerAtLaunch { server.start() }
// Owning the permissions means asking for them at a sensible moment —
// now, with someone at the keyboard — rather than halfway through a
// service when a module first reaches for the microphone.
if firstRun { requestDeclaredPermissions() }
}
/// Something else is quitting us — a logout, a restart, `osascript … quit`.
/// Stop the server first, but never let that wedge a logout: the backstop
/// leaves anyway if the reply hasn't gone out in time.
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard server.state.isBusy else { return .terminateNow }
LogStore.shared.launcher("quitting — stopping ProdDash first")
server.stop()
replyWhenStopped(within: 36)
DispatchQueue.global().asyncAfter(deadline: .now() + 12) { exit(0) }
return .terminateLater
}
/// Waiting here with a Timer would hang the quit outright: while the app
/// sits on `.terminateLater` the run loop is not in its default mode, so a
/// scheduled timer never fires.
private func replyWhenStopped(within ticks: Int) {
guard server.state.isBusy, ticks > 0 else {
return NSApp.reply(toApplicationShouldTerminate: true)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.replyWhenStopped(within: ticks - 1)
}
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
/// A Cocoa app doesn't run its normal quit path when something sends it a
/// signal — `killall`, a logout, a stop from a terminal. Without this the
/// launcher would vanish and leave node (and its audio capture) running.
private func catchShutdownSignals() {
for number in [SIGTERM, SIGINT, SIGHUP] {
signal(number, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
source.setEventHandler { [weak self] in
LogStore.shared.launcher("signal \(number) — stopping ProdDash and quitting")
self?.server.stop()
self?.exitOnceStopped(within: 32)
}
source.resume()
signalSources.append(source)
}
}
private func exitOnceStopped(within ticks: Int) {
guard server.state.isBusy, ticks > 0 else { exit(0) }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.exitOnceStopped(within: ticks - 1)
}
}
/// A second launch just shows the copy that is already running.
private func handOffToRunningInstance() -> Bool {
guard let id = Bundle.main.bundleIdentifier else { return false }
let mine = ProcessInfo.processInfo.processIdentifier
let others = NSRunningApplication.runningApplications(withBundleIdentifier: id)
.filter { $0.processIdentifier != mine }
guard !others.isEmpty else { return false }
DistributedNotificationCenter.default().post(name: Self.showNotification, object: nil)
NSApp.terminate(nil)
return true
}
// MARK: - Status item
private func setUpStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let menu = NSMenu()
menu.delegate = self
menu.autoenablesItems = false
item.menu = menu
statusItem = item
// The glyph is drawn, not templated, so it has to be redrawn when the
// menu bar switches between light and dark.
appearanceObserver = item.button?.observe(\.effectiveAppearance) { [weak self] _, _ in
DispatchQueue.main.async { self?.updateStatusItem() }
}
}
private func updateStatusItem() {
guard let button = statusItem?.button else { return }
button.image = StatusIcon.image(for: server.state, healthy: server.healthy)
button.toolTip = "ProdDash — \(server.state.label)"
}
// MARK: - Menu
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
let version = server.shellVersion.isEmpty ? "" : " \(server.shellVersion)"
menu.addItem(disabled("ProdDash\(version) — \(server.state.label)"))
switch server.state {
case .running:
menu.addItem(disabled("localhost:\(server.port)"))
case .failed(let why):
menu.addItem(disabled(why.prefix(60) + (why.count > 60 ? "…" : "")))
default:
if !server.message.isEmpty { menu.addItem(disabled(String(server.message.prefix(60)))) }
}
menu.addItem(.separator())
let dashboard = item("Open Dashboard", #selector(openDashboard), key: "d")
dashboard.isEnabled = server.state == .running
menu.addItem(dashboard)
let admin = item("Open Admin", #selector(openAdmin), key: "a")
admin.isEnabled = server.state == .running
menu.addItem(admin)
menu.addItem(.separator())
switch server.state {
case .running, .starting:
menu.addItem(item("Stop ProdDash", #selector(toggleServer)))
case .stopping:
menu.addItem(disabled("Stopping…"))
case .stopped, .failed:
menu.addItem(item("Start ProdDash", #selector(toggleServer)))
}
let restart = item("Restart ProdDash", #selector(restartServer))
restart.isEnabled = server.state == .running || server.state == .starting
menu.addItem(restart)
menu.addItem(.separator())
menu.addItem(item("Launcher…", #selector(showWindow), key: ","))
let login = item("Open at Login", #selector(toggleLoginItem))
login.state = LoginItem.isEnabled ? .on : .off
menu.addItem(login)
menu.addItem(.separator())
menu.addItem(item("Quit ProdDash", #selector(quit), key: "q"))
}
private func item(_ title: String, _ action: Selector, key: String = "") -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
item.target = self
item.isEnabled = true
return item
}
private func disabled(_ title: any StringProtocol) -> NSMenuItem {
let item = NSMenuItem(title: String(title), action: nil, keyEquivalent: "")
item.isEnabled = false
return item
}
// MARK: - Actions
@objc private func openDashboard() { server.openDashboard() }
@objc private func openAdmin() { server.openAdmin() }
@objc private func toggleServer() { server.toggle() }
@objc private func restartServer() { server.restart() }
@objc func showWindow() {
if windowController == nil {
windowController = LauncherWindowController(delegate: self)
}
NSApp.activate(ignoringOtherApps: true)
windowController?.showWindow(nil)
windowController?.window?.makeKeyAndOrderFront(nil)
}
/// Close the window but stay in the menu bar.
func hideWindow() {
windowController?.window?.orderOut(nil)
}
@objc private func toggleLoginItem() {
let turningOn = !LoginItem.isEnabled
if let error = LoginItem.set(turningOn) {
present(alert: "Couldn't change the login item", detail: error)
return
}
LogStore.shared.launcher("open at login: \(turningOn ? "on" : "off")")
if turningOn && LoginItem.needsApproval {
present(alert: "Approve ProdDash in Login Items",
detail: "macOS needs you to allow ProdDash under System Settings → General → Login Items.",
openSettings: true)
}
}
/// A modal run started while the menu is still closing doesn't reliably
/// appear — let the menu finish first, then ask.
@objc func quit() {
DispatchQueue.main.async { [weak self] in self?.confirmThenQuit() }
}
private func confirmThenQuit() {
// A booth machine shouldn't lose its dashboard to a stray click.
if server.state == .running || server.state == .starting {
let alert = NSAlert()
alert.messageText = "Quit ProdDash?"
alert.informativeText = "The server stops with the launcher, and every dashboard on the network goes dark."
alert.addButton(withTitle: "Quit")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
NSApp.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else { return }
}
// Deliberately not NSApp.terminate: begun here, while the alert's modal
// session is still unwinding, it leaves AppKit wedged with the main
// queue unserviced — the server stops and the app never finishes
// quitting. Stopping is our own job anyway, so do it and leave.
DispatchQueue.main.async { [weak self] in
guard let self else { exit(0) }
LogStore.shared.launcher("quitting — stopping ProdDash first")
self.server.stop()
self.exitOnceStopped(within: 36)
}
}
// MARK: - Prompts
/// Something already holds the port. If it answers like ProdDash, offer the
/// same hand-over Start ProdDash.command does — that other copy was very
/// likely started over SSH and has no microphone access.
private func askAboutBusyPort(_ port: Int, isProdDash: Bool, reply: @escaping (Bool) -> Void) {
let alert = NSAlert()
if isProdDash {
alert.messageText = "ProdDash is already running on port \(port)"
alert.informativeText = "It was started outside this launcher — over SSH or from Start ProdDash.command "
+ "— so it has no microphone access and this app can't watch over it. Take it over?"
alert.addButton(withTitle: "Take Over")
} else {
alert.messageText = "Port \(port) is already in use"
alert.informativeText = "Another app is listening on port \(port). Stop that app, or give ProdDash a "
+ "different port under Settings. Stopping whatever holds it anyway is rarely what you want."
alert.addButton(withTitle: "Stop It Anyway")
}
alert.addButton(withTitle: "Cancel")
alert.alertStyle = isProdDash ? .informational : .warning
NSApp.activate(ignoringOtherApps: true)
reply(alert.runModal() == .alertFirstButtonReturn)
}
/// Ask for what the installed modules actually declare they need.
func requestDeclaredPermissions() {
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
for need in permissions.needs {
switch need.kind {
case .microphone:
if permissions.microphone == .notDetermined { permissions.requestMicrophone() }
case .localNetwork:
permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir)
case .other:
break
}
}
}
/// Deferred for the same reason the quit confirmation is: a modal run
/// started while the menu is still closing doesn't reliably appear.
private func present(alert title: String, detail: String, openSettings: Bool = false) {
DispatchQueue.main.async { [weak self] in
self?.runAlert(title: title, detail: detail, openSettings: openSettings)
}
}
private func runAlert(title: String, detail: String, openSettings: Bool) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = detail
alert.addButton(withTitle: openSettings ? "Open System Settings" : "OK")
if openSettings { alert.addButton(withTitle: "Later") }
NSApp.activate(ignoringOtherApps: true)
if alert.runModal() == .alertFirstButtonReturn && openSettings {
LoginItem.openSystemSettings()
}
}
}
+185
View File
@@ -0,0 +1,185 @@
import Foundation
/**
The ProdDash server process, spawned into its OWN process group.
Why not Foundation's `Process`: the server starts children of its own — the
Timers module runs `ltc-capture`, which holds an audio input device open. If
the launcher force-kills only node, that capture tool is orphaned and keeps
the device (and the microphone indicator) until someone hunts it down in
Activity Monitor. `posix_spawn` with POSIX_SPAWN_SETPGROUP makes the server a
process-group leader, so `kill(-pid, …)` reaches the whole tree.
Output arrives line by line on the main queue; `onExit` fires once, also on
main, with the exit code and whether a signal killed it.
*/
final class ChildProcess {
/// A spawn that never got off the ground, with something an operator can act on.
struct SpawnError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
let pid: pid_t
/// What we ran, for the log.
let commandLine: String
/// Held for the life of the child: a FileHandle's readability handler does
/// not retain it, so letting these go closes the pipes and the output
/// silently stops.
private let outPipe: Pipe
private let errPipe: Pipe
private var exitSource: DispatchSourceProcess?
private var terminated = false
private init(pid: pid_t, commandLine: String, outPipe: Pipe, errPipe: Pipe) {
self.pid = pid
self.commandLine = commandLine
self.outPipe = outPipe
self.errPipe = errPipe
}
deinit { releasePipes() }
// MARK: - Spawning
static func run(executable: String,
arguments: [String],
directory: String,
environment: [String: String],
onLine: @escaping (String, Bool) -> Void,
onExit: @escaping (Int32, Bool) -> Void) throws -> ChildProcess {
let outPipe = Pipe()
let errPipe = Pipe()
var actions: posix_spawn_file_actions_t?
posix_spawn_file_actions_init(&actions)
defer { posix_spawn_file_actions_destroy(&actions) }
// The server never reads stdin; /dev/null keeps a stray read from blocking it.
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
posix_spawn_file_actions_adddup2(&actions, outPipe.fileHandleForWriting.fileDescriptor, 1)
posix_spawn_file_actions_adddup2(&actions, errPipe.fileHandleForWriting.fileDescriptor, 2)
posix_spawn_file_actions_addchdir_np(&actions, directory)
var attr: posix_spawnattr_t?
posix_spawnattr_init(&attr)
defer { posix_spawnattr_destroy(&attr) }
// SETPGROUP + pgroup 0: the child leads its own group (see the note above).
// CLOEXEC_DEFAULT: nothing but the three descriptors above is inherited.
posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT))
posix_spawnattr_setpgroup(&attr, 0)
let argv: [UnsafeMutablePointer<CChar>?] = ([executable] + arguments).map { strdup($0) } + [nil]
let envp: [UnsafeMutablePointer<CChar>?] = environment
.map { strdup("\($0.key)=\($0.value)") } + [nil]
defer {
for p in argv where p != nil { free(p) }
for p in envp where p != nil { free(p) }
}
var pid: pid_t = 0
let rc = posix_spawn(&pid, executable, &actions, &attr, argv, envp)
guard rc == 0 else {
outPipe.closeBothEnds()
errPipe.closeBothEnds()
throw SpawnError(message: "could not start \(executable): \(String(cString: strerror(rc))) (\(rc))")
}
let child = ChildProcess(pid: pid,
commandLine: ([executable] + arguments).joined(separator: " "),
outPipe: outPipe, errPipe: errPipe)
// Our copies of the write ends must go, or the reads never see EOF.
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForWriting.close()
child.pump(outPipe.fileHandleForReading, isError: false, onLine: onLine)
child.pump(errPipe.fileHandleForReading, isError: true, onLine: onLine)
let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main)
source.setEventHandler { [weak child] in
var status: Int32 = 0
waitpid(pid, &status, 0)
child?.exitSource?.cancel()
child?.exitSource = nil
// Give the last lines a moment to arrive before letting the pipes go.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { child?.releasePipes() }
// Low 7 bits hold the signal that killed it; 0 means a normal exit.
let signal = status & 0x7f
if signal == 0 {
onExit((status >> 8) & 0xff, false)
} else {
onExit(signal, true)
}
}
child.exitSource = source
source.resume()
return child
}
/// Deliver whole lines on the main queue; partial trailing text waits for the rest.
private func pump(_ handle: FileHandle, isError: Bool, onLine: @escaping (String, Bool) -> Void) {
var buffer = Data()
handle.readabilityHandler = { file in
let chunk = file.availableData
if chunk.isEmpty { // EOF — the child closed this end
file.readabilityHandler = nil
if !buffer.isEmpty, let tail = String(data: buffer, encoding: .utf8) {
DispatchQueue.main.async { onLine(tail, isError) }
}
try? file.close()
return
}
buffer.append(chunk)
var lines: [String] = []
while let nl = buffer.firstIndex(of: 0x0a) {
let raw = buffer.subdata(in: buffer.startIndex..<nl)
buffer.removeSubrange(buffer.startIndex...nl)
lines.append(String(data: raw, encoding: .utf8) ?? "")
}
if lines.isEmpty { return }
DispatchQueue.main.async { for line in lines { onLine(line, isError) } }
}
}
private func releasePipes() {
for handle in [outPipe.fileHandleForReading, errPipe.fileHandleForReading] {
handle.readabilityHandler = nil
try? handle.close()
}
}
// MARK: - Stopping
var isAlive: Bool { kill(pid, 0) == 0 }
/// SIGTERM to the whole group. The server unmounts its modules and exits
/// within ~1.5 s; `forceKill()` is the backstop if it doesn't.
func terminate() {
guard !terminated else { return }
terminated = true
signalGroup(SIGTERM)
}
func forceKill() {
signalGroup(SIGKILL)
}
private func signalGroup(_ sig: Int32) {
// Only address the group when the child really leads one — otherwise a
// negative pid could reach processes that are none of our business.
if getpgid(pid) == pid {
kill(-pid, sig)
} else {
kill(pid, sig)
}
}
}
private extension Pipe {
func closeBothEnds() {
try? fileHandleForReading.close()
try? fileHandleForWriting.close()
}
}
+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
+3 -4
View File
@@ -1,9 +1,8 @@
{ {
"id": "clock", "id": "clock",
"name": "Clock", "name": "Clock",
"version": "1.0.0", "version": "1.0.1",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Big booth clock — the smallest possible ProdDash module",
"client": "client.js", "client": "client.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": { "w": 2, "h": 1 },
@@ -18,7 +17,7 @@
{ "value": "12h", "label": "12-hour" } { "value": "12h", "label": "12-hour" }
] ]
}, },
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, "showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
"showDate": { "type": "boolean", "label": "Show date", "default": false } "showDate": { "type": "boolean", "label": "Date", "default": false }
} }
} }
+26 -7
View File
@@ -209,17 +209,27 @@ export default function create({ root, moduleApi }) {
renderClock(); renderClock();
} }
/**
* The header clock follows the service the server says owns the clock
* (state.service: idle → running → stopped, by schedule or by the admin's
* ProPresenter document cues): a countdown before it, the running time
* while it runs (with ahead/behind when following ProPresenter), the final
* time once it has ended.
*/
function renderClock() { function renderClock() {
const plan = state?.plan; const plan = state?.plan;
const live = state?.live || {}; const live = state?.live || {};
const svc = state?.service || {};
clockLabelEl.textContent = ''; clockLabelEl.textContent = '';
clockValueEl.textContent = ''; clockValueEl.textContent = '';
clockValueEl.className = 'pp-clock-value'; clockValueEl.className = 'pp-clock-value';
if (!plan || headEl.hidden) return; if (!plan || headEl.hidden) return;
const now = serverNow(); const now = serverNow();
if (on('showRunningClock') && live.following && live.serviceStartedAt) { const startedAt = svc.startedAt || live.serviceStartedAt || 0;
if (on('showRunningClock') && svc.phase === 'running' && startedAt) {
clockLabelEl.textContent = 'Running'; clockLabelEl.textContent = 'Running';
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000); clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
if (live.following) {
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take. // 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 done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0); const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
@@ -229,16 +239,25 @@ export default function create({ root, moduleApi }) {
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
} }
}
return; return;
} }
if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) { if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
clockLabelEl.textContent = 'Ended';
clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
return;
}
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
if (on('showCountdown') && startsAt > now) {
clockLabelEl.textContent = 'Starts in'; clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000); clockValueEl.textContent = fmtDur((startsAt - now) / 1000);
return; return;
} }
if (on('showRunningClock') && plan.serviceStartsAt) { if (on('showRunningClock') && startsAt) {
clockLabelEl.textContent = 'Since start'; // Past the scheduled start and not started: waiting for the admin's
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000); // start document to go live in ProPresenter (or its failsafe).
clockLabelEl.textContent = 'Scheduled';
clockValueEl.textContent = fmtClock(startsAt);
} }
} }
+85
View File
@@ -0,0 +1,85 @@
'use strict';
/**
* Title matching for the PCO Plan module — the one answer to "is this
* ProPresenter document that Planning Center thing?", used twice:
*
* - mapping the document ProPresenter has live to a plan item (the
* highlight and the item runtimes), and
* - recognising the documents an admin picked as the service start / end
* cues (see service-timing.js).
*
* A ProPresenter name and a Planning Center title rarely agree letter for
* letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides
* are tidied the way propresenter-core.cleanItemName does (tags in brackets,
* leading date codes and dash separators dropped), lower-cased, stripped of
* punctuation and compared:
*
* 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop"
* 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements"
* Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67
*
* Filler words ("the", "of", "and"…) are ignored. The thresholds:
*
* MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because
* the item that scores best *and* is next in the
* plan wins, so a near miss lands on the right row.
* TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service
* clock, so one wrong word must not fire it: the
* pre-service loop (0.67 against "Post Service Loop")
* stays a non-match, while a renamed
* "Post-Service Loop", a tagged "Post Service Loop
* [Full]" (1.0) or a shortened "Post Service" (0.85)
* still trigger.
*/
const MATCH_THRESHOLD = 0.6;
const TRIGGER_THRESHOLD = 0.75;
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
function cleanTitle(raw) {
const original = String(raw || '').trim();
let s = original;
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
s = s.replace(/\s[-–—]+\s/g, ' ');
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
s = s.replace(/\s{2,}/g, ' ').trim();
return s || original;
}
function normalize(raw) {
return cleanTitle(raw)
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[’'`]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
function tokens(norm) {
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
}
/** 0..1 similarity between two titles. */
function similarity(a, b) {
const na = normalize(a);
const nb = normalize(b);
if (!na || !nb) return 0;
if (na === nb) return 1;
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
const ta = tokens(na);
const tb = tokens(nb);
if (!ta.size || !tb.size) return 0;
let common = 0;
for (const t of ta) if (tb.has(t)) common += 1;
return (2 * common) / (ta.size + tb.size); // Dice coefficient
}
/** Does a live ProPresenter document count as the cue document an admin picked? */
function isCue(liveName, cueDocument) {
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
}
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
+65 -13
View File
@@ -1,9 +1,10 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.3.0", "version": "1.4.0",
"proddash": ">=1.3.0", "proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services, following ProPresenter live", "description": "Order of service from Planning Center Services",
"provides": ["timers"],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
@@ -12,13 +13,16 @@
"configGroups": { "configGroups": {
"Planning Center credentials": { "Planning Center credentials": {
"collapsed": "whenSet", "collapsed": "whenSet",
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers." "help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
},
"Service timing": {
"help": "Each service in the plan takes its turn owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time — or, with document triggers on, the moment the chosen Start document goes live in ProPresenter (falling back to the schedule after the start failsafe) — and ends when the End document goes live, or when the next service takes over. The document lists hold what ProPresenter has shown or listed since ProdDash started plus what is saved here; names are matched loosely, so a renamed \"Post-Service Loop\" still counts."
} }
}, },
"configSchema": { "configSchema": {
"appId": { "appId": {
"type": "string", "type": "string",
"label": "Application ID", "label": "Client ID",
"default": "", "default": "",
"group": "Planning Center credentials" "group": "Planning Center credentials"
}, },
@@ -30,7 +34,7 @@
}, },
"accessToken": { "accessToken": {
"type": "password", "type": "password",
"label": "OAuth 2.0 access token (instead of Application ID + Secret)", "label": "OAuth 2.0 access token (instead of Client ID + Secret)",
"default": "", "default": "",
"group": "Planning Center credentials" "group": "Planning Center credentials"
}, },
@@ -38,8 +42,7 @@
"type": "select", "type": "select",
"label": "Service type", "label": "Service type",
"default": "", "default": "",
"optionsRoute": "/service-types", "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": { "planId": {
"type": "select", "type": "select",
@@ -47,18 +50,67 @@
"default": "", "default": "",
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }], "options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
"optionsRoute": "/plans", "optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId", "optionsDependsOn": "serviceTypeId"
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
}, },
"pollSeconds": { "pollSeconds": {
"type": "number", "type": "number",
"label": "Re-read the plan every N seconds", "label": "Refresh Interval (s)",
"default": 30 "default": 30
}, },
"followProPresenter": { "followProPresenter": {
"type": "switch", "type": "switch",
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it", "label": "Follow ProPresenter Module",
"default": true "default": true
},
"preRollMinutes": {
"type": "number",
"label": "Pre-roll (min)",
"default": 30,
"group": "Service timing",
"help": "How long before a service's scheduled start the clock, item timing and countdown switch to it."
},
"useDocumentTriggers": {
"type": "switch",
"label": "ProPresenter document triggers",
"default": false,
"group": "Service timing"
},
"serviceStartTrigger": {
"type": "select",
"label": "Service start",
"default": "schedule",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"options": [
{ "value": "schedule", "label": "Scheduled service time" },
{ "value": "document", "label": "ProPresenter document" }
]
},
"serviceStartDocument": {
"type": "select",
"label": "Start document",
"default": "",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"optionsRoute": "/documents",
"help": "The document that goes live as the service begins."
},
"startGraceMinutes": {
"type": "number",
"label": "Start failsafe (min)",
"default": 10,
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway."
},
"serviceEndDocument": {
"type": "select",
"label": "End document",
"default": "",
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"optionsRoute": "/documents",
"help": "The document that goes live as the service ends; it freezes the service clock."
} }
}, },
"instanceGroups": { "instanceGroups": {
@@ -72,7 +124,7 @@
"Show / hide": { "Show / hide": {
"collapsed": true, "collapsed": true,
"columns": 3, "columns": 3,
"help": "Untick anything this tile should not show." "help": "Switch off anything this tile should not show."
} }
}, },
"instanceSchema": { "instanceSchema": {
+620 -111
View File
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
'use strict';
/**
* Service timing for the PCO Plan module — pure functions, no I/O, no
* timers. server.js feeds them the plan's service times, the documents
* ProPresenter puts live and the clock; service-timing.test.js calls them
* directly. Two questions are answered here:
*
* 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService
* 2. When does that service count as started, and as ended? → advance
*
* ── Service windows ────────────────────────────────────────────────────
*
* A plan can have several services (plan_times of type "service": 9:00 and
* 11:00, say). Only ONE owns the service-elapsed clock, the current-item
* timing and the countdown at any moment, and moving into the next
* service's window resets all three. Each service's window runs from the
* end of the previous one to a boundary the next service's pre-roll sets:
*
* boundary(i → i+1) = min( start[i+1],
* max( start[i+1] − preRoll, endOf[i] ) )
*
* endOf[i] = the moment service i ended (its end cue fired), else its
* scheduled end (plan_time ends_at, else start + the planned
* length of the "during" items, else 90 min).
*
* In words: the next service takes over `preRoll` before its scheduled
* start (30 min by default; admin "Pre-roll"), but never before the current
* one has ended or was due to end, and never later than its own start. A
* service still running at the boundary is stopped there — that is the
* "next service's start" failsafe the end cue has. The first window opens at
* the beginning of time (before 9:00 the day belongs to the 9:00 service:
* countdown running, service idle); the last window never closes — the last
* service stays "stopped" until the plan itself changes (the next day's plan
* takes over through the plan picker). The last service also has a failsafe
* end: its scheduled end + LAST_SERVICE_OVERRUN_MS.
*
* ── Start and end of a service ─────────────────────────────────────────
*
* phase: idle → running → stopped, never backwards within one window.
*
* start, schedule mode (default) at the scheduled start; startedAt = it.
* start, document mode when the start document goes live
* (edge: it has to *become* live, from
* pre-roll before the scheduled start
* onwards — a rehearsal an hour earlier
* that shows the same document is not the
* service). Failsafe: if the document has
* not gone live by start + grace (admin
* "Start failsafe", 10 min default), the
* service starts anyway, counting from
* the scheduled start — the best estimate
* of when it really began.
* end, document when the end document goes live while
* the service is running (an end document
* live before the start — the previous
* service's loop — is ignored). Failsafe
* either way: the window boundary (see
* above), or, for the last service, its
* scheduled end + LAST_SERVICE_OVERRUN_MS.
*
* Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see
* matching.js for the tolerance), so "Post-Service Loop" still ends the
* service when ProPresenter calls it "Post Service Loop".
*/
const { similarity, TRIGGER_THRESHOLD } = require('./matching');
const MINUTE = 60000;
/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */
const DEFAULT_PRE_ROLL_MS = 30 * MINUTE;
/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */
const DEFAULT_START_GRACE_MS = 10 * MINUTE;
/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */
const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE;
/** A service with no end time and no planned item lengths is assumed this long. */
const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE;
/** The plan times that are services (every timed one when none is typed "service"), earliest first. */
function serviceTimes(planTimes) {
const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : [];
const services = list.filter((t) => t.timeType === 'service');
return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt);
}
/**
* The window each service owns (see the file comment).
*
* @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs)
* @param {object} [opts]
* @param {number} [opts.preRollMs] how early the next service may take over (default 30 min)
* @param {number} [opts.plannedMs] planned length of the service — for services without ends_at
* @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue
* @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>}
*/
function serviceWindows(planTimes, opts = {}) {
const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS;
const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0;
const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {};
const services = serviceTimes(planTimes);
const windows = services.map((svc, i) => {
const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS);
const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd;
const next = services[i + 1];
const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity;
return {
id: String(svc.id),
name: String(svc.name || ''),
index: i,
count: services.length,
startsAt: svc.startsAt,
endsAt: scheduledEnd,
windowFrom: -Infinity, // filled in below
windowUntil,
cueFrom: svc.startsAt - preRoll, // a start document counts from here on
failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS,
isLast: !next,
};
});
for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil;
return windows;
}
/** The window that holds `now` — null only when there are no services. */
function selectService(windows, now) {
if (!Array.isArray(windows) || !windows.length) return null;
return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1];
}
/** Timing state for a service that has just come into its window. */
function freshService(window) {
return {
id: window ? String(window.id) : '',
phase: window ? 'idle' : 'none',
startedAt: 0,
endedAt: 0,
startTrigger: '', // 'schedule' | 'document' | 'failsafe'
endTrigger: '', // 'document' | 'next-service' | 'plan-end'
cue: '', // the document that started / ended it
};
}
function fmtTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
/**
* Move a service's timing on by a clock tick or by a document going live.
* Pure: returns a new state and a one-line description of what happened
* (for the log), or `changed: false`.
*
* @param {object} svc state from freshService(window) / a previous advance()
* @param {object} window from serviceWindows()
* @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs }
* @param {number} now ms epoch
* @param {string} [cue] the document that just went live (omit on a plain tick)
* @returns {{ svc: object, changed: boolean, event: string }}
*/
function advance(svc, window, opts, now, cue) {
const o = opts || {};
const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS;
const cueName = String(cue || '').trim();
const same = { svc, changed: false, event: '' };
if (!svc || !window || svc.id !== String(window.id)) return same;
const at = fmtTime(window.startsAt);
if (svc.phase === 'idle') {
const documentMode = o.startMode === 'document' && o.startDocument;
if (documentMode) {
if (cueName && now >= window.cueFrom) {
const score = similarity(cueName, o.startDocument);
if (score >= TRIGGER_THRESHOLD) {
return {
svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName },
changed: true,
event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`,
};
}
}
if (now >= window.startsAt + grace) {
return {
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' },
changed: true,
event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`,
};
}
return same;
}
if (now >= window.startsAt) {
return {
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' },
changed: true,
event: `started — scheduled ${at}`,
};
}
return same;
}
if (svc.phase === 'running') {
if (o.endDocument && cueName) {
const score = similarity(cueName, o.endDocument);
if (score >= TRIGGER_THRESHOLD) {
return {
svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName },
changed: true,
event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`,
};
}
}
if (now >= window.failsafeEndAt) {
const endedAt = Math.min(now, window.failsafeEndAt);
return {
svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' },
changed: true,
event: window.isLast
? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}`
: `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`,
};
}
return same;
}
return same;
}
module.exports = {
MINUTE,
DEFAULT_PRE_ROLL_MS,
DEFAULT_START_GRACE_MS,
LAST_SERVICE_OVERRUN_MS,
DEFAULT_SERVICE_LENGTH_MS,
serviceTimes,
serviceWindows,
selectService,
freshService,
advance,
fmtTime,
};
+229
View File
@@ -0,0 +1,229 @@
'use strict';
/**
* Unit tests for the PCO Plan module's pure service-timing logic.
*
* node modules/pco-plan/service-timing.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Times are
* built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min
* pre-roll) so the expectations read like the day would.
*/
const assert = require('assert');
const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching');
const {
MINUTE,
LAST_SERVICE_OVERRUN_MS,
serviceWindows,
selectService,
freshService,
advance,
} = require('./service-timing');
const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight
const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE;
const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const PLAN_TIMES = [
{ id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) },
{ id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) },
{ id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) },
];
const PRE_ROLL = 30 * MINUTE;
const OPTS = { preRollMs: PRE_ROLL };
let passed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
console.log(` FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
throw err;
}
}
console.log('service windows');
test('rehearsal is not a service; windows are contiguous and ordered', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']);
assert.strictEqual(w[0].windowFrom, -Infinity);
assert.strictEqual(w[0].windowUntil, w[1].windowFrom);
assert.strictEqual(w[1].windowUntil, Infinity);
assert.strictEqual(w[0].count, 2);
});
test('the boundary is 30 min before the next start once the first was due to end', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
// 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30)));
assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary');
});
test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => {
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE });
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)');
const [tight] = serviceWindows(
[
{ id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) },
{ id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) },
],
OPTS
);
assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start');
});
test('an early actual end lets the next window open at pre-roll', () => {
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } });
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05');
const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } });
assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30');
});
test('a service without ends_at gets the planned length, else 90 min', () => {
const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE });
assert.strictEqual(hm(w.endsAt), hm(at(10, 10)));
const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {});
assert.strictEqual(hm(w90.endsAt), hm(at(10, 30)));
assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS);
});
test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
assert.strictEqual(selectService(w, at(8, 50)).id, 's1');
assert.strictEqual(selectService(w, at(9, 30)).id, 's1');
assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary');
assert.strictEqual(selectService(w, at(10, 55)).id, 's2');
assert.strictEqual(selectService(w, at(11, 5)).id, 's2');
assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service');
assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service');
assert.strictEqual(selectService([], at(9, 0)), null);
});
console.log('service start');
const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE };
const DOCUMENT = {
startMode: 'document',
startDocument: 'Welcome & Announcements',
endDocument: 'Post Service Loop',
startGraceMs: 10 * MINUTE,
};
test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
let s = freshService(w1);
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode');
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false);
const out = advance(s, w1, SCHEDULE, at(9, 0, 30));
assert.strictEqual(out.changed, true);
s = out.svc;
assert.strictEqual(s.phase, 'running');
assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed');
assert.strictEqual(s.startTrigger, 'schedule');
});
test('document mode: the start document going live starts the service at that moment', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it');
const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]');
assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts');
assert.strictEqual(out.svc.phase, 'running');
assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start');
assert.strictEqual(out.svc.startTrigger, 'document');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing');
});
test('document mode: the start document is only a cue from pre-roll before the start', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service');
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts');
});
test('document mode: failsafe at start + grace counts from the scheduled start', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = freshService(w1);
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false);
const out = advance(s, w1, DOCUMENT, at(9, 10));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'running');
assert.strictEqual(out.svc.startedAt, at(9, 0));
assert.strictEqual(out.svc.startTrigger, 'failsafe');
assert.match(out.event, /failsafe/);
});
test('document mode: a cue and the failsafe in the same tick — the cue wins', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements');
assert.strictEqual(out.svc.startTrigger, 'document');
assert.strictEqual(out.svc.startedAt, at(9, 12));
});
console.log('service end');
test('the end document freezes a running service; a second start document is ignored', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing');
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)');
const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop');
assert.strictEqual(out.changed, true, 'a hyphenated end document still counts');
s = out.svc;
assert.strictEqual(s.phase, 'stopped');
assert.strictEqual(s.endedAt, at(10, 12));
assert.strictEqual(s.endTrigger, 'document');
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped');
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false);
});
test('failsafe: a service still running when the next window opens stops at the boundary', () => {
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc;
assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed');
const out = advance(s, w1, SCHEDULE, at(10, 31));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'stopped');
assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick');
assert.strictEqual(out.svc.endTrigger, 'next-service');
});
test('failsafe: the last service stops an hour after its scheduled end', () => {
const [, w2] = serviceWindows(PLAN_TIMES, OPTS);
const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc;
assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false);
const out = advance(s, w2, SCHEDULE, at(13, 15));
assert.strictEqual(out.changed, true);
assert.strictEqual(out.svc.phase, 'stopped');
assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15)));
assert.strictEqual(out.svc.endTrigger, 'plan-end');
});
test('the end document does not stop a service in the wrong window', () => {
const w = serviceWindows(PLAN_TIMES, OPTS);
const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together');
});
console.log('matching');
test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => {
assert.strictEqual(MATCH_THRESHOLD, 0.6);
assert.strictEqual(TRIGGER_THRESHOLD, 0.75);
assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1);
assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1);
assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85);
assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001);
assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false);
assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true);
assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true);
assert.strictEqual(isCue('Anything', ''), false);
});
console.log(`\n${passed} tests passed`);
+70 -44
View File
@@ -2,9 +2,9 @@
Ported from prodcom-listener/public/app.js into the ProdDash module Ported from prodcom-listener/public/app.js into the ProdDash module
contract: everything lives inside the tile's root, per-tile state contract: everything lives inside the tile's root, per-tile state
(hidden channels, timestamps, text size) persists in instance settings, (hidden channels, channel icons, timestamps, text size, flow direction)
and all network traffic goes through moduleApi (the module's server persists in instance settings, and all network traffic goes through
proxy at /prodcom/*). */ moduleApi (the module's server proxy at /prodcom/*). */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* ── per-instance state ─────────────────────────────────────────── */ /* ── per-instance state ─────────────────────────────────────────── */
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
const channels = new Map(); // channelId -> {name, color} const channels = new Map(); // channelId -> {name, color}
const groups = new Map(); // groupId -> {name, channelIds} const groups = new Map(); // groupId -> {name, channelIds}
const entryEls = new Map(); // entryId -> element const entryEls = new Map(); // entryId -> element
let pinnedToBottom = true; let pinnedToLatest = true;
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle let stream = null; // moduleApi.sse handle
let bootTimer = null; let bootTimer = null;
@@ -40,10 +40,14 @@ export default function create({ root, moduleApi }) {
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'; const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>'; const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>'; const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
const CHANNELS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
// arrow shows where new messages land: down = oldest first, up = newest first
const FLOW_DOWN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
const FLOW_UP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
let channelMenuEl = null; // the open menu's element (filled on each open) let channelMenuEl = null; // the open menu's element (filled on each open)
const channelsMenu = moduleApi.header.addMenu({ const channelsMenu = moduleApi.header.addMenu({
label: 'Channels ▾', icon: CHANNELS_SVG,
title: 'Choose visible channels', title: 'Choose visible channels',
build(menu) { build(menu) {
menu.classList.add('pt-menu'); menu.classList.add('pt-menu');
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
}, },
}); });
channelsMenu.button.classList.add('pt-channels-btn'); channelsMenu.button.classList.add('pt-channels-btn');
// the icon carries the label; a count rides alongside it while filtering
const channelCountEl = document.createElement('span');
channelCountEl.className = 'pt-count';
channelCountEl.hidden = true;
channelsMenu.button.appendChild(channelCountEl);
let timeBtn = null; let timeBtn = null;
timeBtn = moduleApi.header.addButton({ timeBtn = moduleApi.header.addButton({
@@ -71,13 +80,24 @@ export default function create({ root, moduleApi }) {
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({
icon: FLOW_DOWN_SVG,
onClick() {
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
applyInstanceSettings();
// the two directions have opposite "latest" ends — follow the flip
if (pinnedToLatest) scrollToLatest();
},
});
moduleApi.header.addButton({ moduleApi.header.addButton({
icon: JUMP_SVG, icon: JUMP_SVG,
title: 'Jump to latest', title: 'Jump to latest',
onClick() { onClick() {
pinnedToBottom = true; pinnedToLatest = true;
jumpBtn.hidden = true; jumpBtn.hidden = true;
scrollToBottom(); scrollToLatest();
}, },
}); });
@@ -93,25 +113,20 @@ export default function create({ root, moduleApi }) {
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
wrap.style.setProperty('--pt-size', size + 'px'); wrap.style.setProperty('--pt-size', size + 'px');
wrap.classList.toggle('show-times', Boolean(s.showTimes)); wrap.classList.toggle('show-times', Boolean(s.showTimes));
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
timeBtn?.classList.toggle('active', Boolean(s.showTimes)); timeBtn?.classList.toggle('active', Boolean(s.showTimes));
const up = newestFirst();
wrap.classList.toggle('newest-first', up);
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
flowBtn.classList.toggle('active', up);
flowBtn.title = up
? 'Newest at top — click for newest at bottom'
: 'Newest at bottom — click for newest at top';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
} }
applyInstanceSettings(); applyInstanceSettings();
/* ── channel filter (instance setting) ──────────────────────────── */
function channelFilter() {
return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase();
}
/** Entry hidden by the hard per-tile filter (settings.channel)? */
function filteredOut(channelId, channelName) {
const want = channelFilter();
if (!want) return false;
const known = channels.get(channelId);
const name = ((known && known.name) || channelName || '').toLowerCase();
return name !== want && String(channelId).toLowerCase() !== want;
}
/* ── channel visibility toggles ─────────────────────────────────── */ /* ── channel visibility toggles ─────────────────────────────────── */
function persistHidden() { function persistHidden() {
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
} }
function applyFilter(el) { function applyFilter(el) {
const hidden = hiddenChannels.has(el.dataset.channelId) el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
el.classList.toggle('hidden-by-filter', hidden);
} }
function toggleChannel(id) { function toggleChannel(id) {
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
persistHidden(); persistHidden();
rebuildChannelMenu(); rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el)); entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
} }
/** Show exactly this group's channels, hide the rest */ /** Show exactly this group's channels, hide the rest */
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
persistHidden(); persistHidden();
rebuildChannelMenu(); rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el)); entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
} }
function groupIsActive(g) { function groupIsActive(g) {
@@ -150,18 +163,24 @@ export default function create({ root, moduleApi }) {
); );
} }
function updateChannelsLabel() { /* The button is an icon, so the "some channels are hidden" state shows as
an accent tint plus a compact count beside it. */
function updateChannelsButton() {
let visibleCount = 0; let visibleCount = 0;
for (const id of channels.keys()) { for (const id of channels.keys()) {
if (!hiddenChannels.has(id)) visibleCount++; if (!hiddenChannels.has(id)) visibleCount++;
} }
channelsMenu.setLabel(visibleCount === channels.size const filtering = channels.size > 0 && visibleCount !== channels.size;
? 'Channels ▾' channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
: `Channels (${visibleCount}/${channels.size}) ▾`); channelCountEl.hidden = !filtering;
channelsMenu.button.classList.toggle('active', filtering);
channelsMenu.button.title = filtering
? `Channels — showing ${visibleCount} of ${channels.size}`
: 'Choose visible channels';
} }
function rebuildChannelMenu() { function rebuildChannelMenu() {
updateChannelsLabel(); updateChannelsButton();
const channelMenu = channelMenuEl; const channelMenu = channelMenuEl;
if (!channelMenu) return; // menu not opened yet — the label is enough if (!channelMenu) return; // menu not opened yet — the label is enough
channelMenu.innerHTML = ''; channelMenu.innerHTML = '';
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
/* ── scrolling ──────────────────────────────────────────────────── */ /* ── scrolling ──────────────────────────────────────────────────── */
/** Newest entry at the top (bottom-up) instead of at the bottom? */
function newestFirst() {
return Boolean(moduleApi.instanceSettings.newestFirst);
}
streamEl.addEventListener('scroll', () => { streamEl.addEventListener('scroll', () => {
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; const near = newestFirst()
pinnedToBottom = nearBottom; ? streamEl.scrollTop < 60
if (nearBottom) jumpBtn.hidden = true; : streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToLatest = near;
if (near) jumpBtn.hidden = true;
}); });
jumpBtn.addEventListener('click', () => { jumpBtn.addEventListener('click', () => {
pinnedToBottom = true; pinnedToLatest = true;
jumpBtn.hidden = true; jumpBtn.hidden = true;
scrollToBottom(); scrollToLatest();
}); });
function scrollToBottom() { /** The latest entry is at the top when flipped, at the bottom otherwise. */
streamEl.scrollTop = streamEl.scrollHeight; function scrollToLatest() {
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
} }
function afterInsert(el) { function afterInsert(el) {
if (el.classList.contains('hidden-by-filter')) return; if (el.classList.contains('hidden-by-filter')) return;
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
else jumpBtn.hidden = false; else jumpBtn.hidden = false;
} }
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
el = document.createElement('div'); el = document.createElement('div');
el.className = 'pt-entry'; el.className = 'pt-entry';
el.dataset.channelId = entry.channelId || ''; el.dataset.channelId = entry.channelId || '';
el.dataset.channelName = entry.channelName || '';
const info = channelInfo(entry); const info = channelInfo(entry);
el.style.setProperty('--ch', info.color); el.style.setProperty('--ch', info.color);
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
afterInsert(el); afterInsert(el);
} else { } else {
updateEntryEl(el, entry); updateEntryEl(el, entry);
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom(); if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
} }
} }
@@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) {
bootTimer = setTimeout(boot, 5000); bootTimer = setTimeout(boot, 5000);
return; return;
} }
scrollToBottom(); scrollToLatest();
connect(); connect();
} }
@@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) {
root.innerHTML = ''; // header controls are removed by the shell root.innerHTML = ''; // header controls are removed by the shell
}, },
onResize() { onResize() {
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
}, },
}; };
} }
+12 -9
View File
@@ -1,9 +1,12 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.0.0", "version": "1.2.1",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProdCom transcript stream", "description": "Live ProdCom transcript stream",
"permissions": [
{ "kind": "local-network", "reason": "Reaching the ProdCom API on this network" }
],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
@@ -22,15 +25,15 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"channel": { "showAvatars": {
"type": "string", "type": "boolean",
"label": "Channel filter (channel name; empty = all)", "label": "Show channel icons",
"default": "" "default": true
}, },
"textSize": { "newestFirst": {
"type": "number", "type": "boolean",
"label": "Text size (px)", "label": "Newest first (transcript reads bottom-up)",
"default": 15 "default": false
}, },
"showTimes": { "showTimes": {
"type": "boolean", "type": "boolean",
+24
View File
@@ -9,6 +9,17 @@
flex-direction: column; flex-direction: column;
} }
/* ── channels button ──
Icon-only; the count appears next to it while some channels are hidden. */
[data-module="prodcom-transcript"] .pt-channels-btn { gap: 3px; }
[data-module="prodcom-transcript"] .pt-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
/* ── channels menu ── /* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu); The menu container is created and positioned by the shell (.tile-menu);
it lives inside the tile, so [data-module] scoping still applies. */ it lives inside the tile, so [data-module] scoping still applies. */
@@ -113,6 +124,7 @@
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent); background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
} }
[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; } [data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; }
[data-module="prodcom-transcript"] .hide-avatars .pt-entry .avatar { display: none; }
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; } [data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
@@ -143,6 +155,12 @@
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; } [data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
/* bottom-up: newest entry at the top, the stream reading upwards */
[data-module="prodcom-transcript"] .newest-first .pt-entries {
display: flex;
flex-direction: column-reverse;
}
/* ── jump-to-latest ── */ /* ── jump-to-latest ── */
[data-module="prodcom-transcript"] .pt-jump { [data-module="prodcom-transcript"] .pt-jump {
@@ -163,3 +181,9 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
} }
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; } [data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
/* flipped, "latest" is the top of the stream — so is the pill */
[data-module="prodcom-transcript"] .newest-first .pt-jump {
top: 12px;
bottom: auto;
}
+4 -1
View File
@@ -1,9 +1,12 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.0.0", "version": "1.0.1",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
File diff suppressed because it is too large Load Diff
+75 -9
View File
@@ -1,15 +1,40 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.2.0", "version": "1.3.0",
"proddash": ">=1.2.0", "proddash": ">=1.4.0",
"description": "Live ProPresenter timers and LTC timecode", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": ["timers"],
"permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 2 }, "defaultSize": { "w": 4, "h": 3 },
"configGroups": {
"ProPresenter": {
"toggle": "ppEnabled",
"help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled."
},
"LTC Timecode": {
"toggle": "ltcEnabled",
"help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)."
},
"PCO Plan": {
"toggle": "pcoEnabled",
"help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side."
}
},
"configSchema": { "configSchema": {
"ppEnabled": {
"type": "switch",
"label": "ProPresenter timers",
"group": "ProPresenter",
"default": true
},
"propresenter": { "propresenter": {
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
@@ -18,13 +43,14 @@
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "API password",
"group": "ProPresenter", "group": "ProPresenter",
"default": "" "default": "",
"help": "Only if a password is set in ProPresenter's network preferences."
}, },
"ltcEnabled": { "ltcEnabled": {
"type": "switch", "type": "switch",
"label": "LTC listener — decode timecode from an audio input on this machine", "label": "LTC timecode",
"group": "LTC Timecode", "group": "LTC Timecode",
"default": false "default": false
}, },
@@ -38,10 +64,17 @@
}, },
"ltcChannel": { "ltcChannel": {
"type": "number", "type": "number",
"label": "Channel on that device (1-based)", "label": "Channel",
"group": "LTC Timecode", "group": "LTC Timecode",
"showWhen": "ltcEnabled", "showWhen": "ltcEnabled",
"default": 1 "default": 1,
"help": "The channel on that device carrying the timecode, counted from 1."
},
"pcoEnabled": {
"type": "switch",
"label": "PCO Plan timers",
"group": "PCO Plan",
"default": true
}, },
"timerFont": { "timerFont": {
"type": "select", "type": "select",
@@ -53,5 +86,38 @@
{ "value": "monospace", "label": "Monospace — steadier changing digits" } { "value": "monospace", "label": "Monospace — steadier changing digits" }
] ]
} }
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 },
"Clock": { "collapsed": false },
"Colours": {
"collapsed": true,
"columns": 2,
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
}
},
"instanceSchema": {
"showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" },
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" },
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" },
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" },
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" },
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
"clockFormat": {
"type": "select",
"label": "Clock format",
"default": "24h",
"group": "Clock",
"options": [
{ "value": "24h", "label": "24-hour" },
{ "value": "12h", "label": "12-hour" }
]
},
"clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" },
"clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" },
"runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" },
"warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" },
"overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" },
"idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" }
} }
} }
File diff suppressed because it is too large Load Diff
+210 -116
View File
@@ -1,11 +1,16 @@
/* ProPresenter Timers module — scoped to its tiles, shell variables only. /* Timers module — scoped to its tiles, shell variables only.
The API exposes no per-timer color, so a card's color IS its state: A card's colour IS its state: running / warning / overrun / idle. The four
accent = running, muted = stopped, warn = complete, danger = overrun. tones are custom properties on the tile root: they default to the shell's
Overrun must be unmistakable at a distance — danger fill + pulse. */ palette (so a dashboard theme reaches them) and the tile's own colour
settings, when changed, are set inline on .tm-wrap and win — the
documented mechanism. Overrun must be unmistakable across the room:
danger fill + pulse. */
[data-module="propresenter-timers"] .tm-wrap { [data-module="propresenter-timers"] .tm-wrap {
--tm-time-size: 32px; --tm-ok: var(--accent);
--tm-ltc-size: 26px; --tm-warn: var(--warn);
--tm-danger: var(--danger);
--tm-muted: var(--muted);
--tm-meta-size: 11px; --tm-meta-size: 11px;
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -15,7 +20,7 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */ /* ── degraded-state banner (the ProdDash feed itself is down) ── */
[data-module="propresenter-timers"] .tm-banner { [data-module="propresenter-timers"] .tm-banner {
flex: none; flex: none;
@@ -30,12 +35,11 @@
} }
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; } [data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
/* Values still on screen while the feed is down are stale — say so visually. /* Values still on screen while their source is down are stale — say so
Per-card, because the reader-fed LTC card is not stale when only visually, per group: the LTC card keeps ticking when only ProPresenter
ProPresenter is unreachable; it dims only when the ProdDash feed itself is unreachable. When the ProdDash feed is down everything is old. */
is down (is-feed-down) and everything on screen really is old. */ [data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; } [data-module="propresenter-timers"] .tm-wrap.is-feed-down .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 { [data-module="propresenter-timers"] .tm-hint {
flex: 1; flex: 1;
@@ -49,7 +53,69 @@
} }
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; } [data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
/* ── card grid (column count set by client.js fit()) ── */ /* ── source groups: heading + card grid (column count set by fit()) ── */
[data-module="propresenter-timers"] .tm-groups {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
}
[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-group {
min-height: 0;
display: flex;
flex-direction: column;
flex: 1 1 0;
}
[data-module="propresenter-timers"] .tm-head {
flex: none;
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
margin: 0 2px 4px;
font-size: 11px;
line-height: 16px;
color: var(--muted);
}
[data-module="propresenter-timers"] .tm-head[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-title {
flex: none;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* The source's own word while it isn't OK ("Unreachable at …"). */
[data-module="propresenter-timers"] .tm-note {
flex: 1;
min-width: 0;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); }
/* Headings off: a source in trouble, or with nothing to show, still says so. */
[data-module="propresenter-timers"] .tm-empty {
flex: none;
padding: 2px 4px;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; }
[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-grid { [data-module="propresenter-timers"] .tm-grid {
flex: 1; flex: 1;
@@ -57,16 +123,16 @@
display: grid; display: grid;
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
gap: var(--tm-gap, 8px); gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
} }
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; } [data-module="propresenter-timers"] .tm-grid:empty { display: none; }
/* Compact cards (short tiles): the state line goes, its color stays. /* Compact cards (short tiles) and "Status lines" off: the lines under the
The one exception is the LTC card with no timecode API behind it — digits go, the colour stays. A note that explains a missing value (no
that message must never be hidden. */ LTC input, a solo timer no longer offered) must never be hidden. */
[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; } [data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; } [data-module="propresenter-timers"] .tm-compact .tm-detail,
[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; } [data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; }
/* ── one timer card ── */ /* ── one timer card ── */
@@ -98,13 +164,20 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
white-space: nowrap; white-space: nowrap;
}
/* The label is its own box so a long name ends in an ellipsis (a flex
container's bare text won't). */
[data-module="propresenter-timers"] .tm-label {
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
[data-module="propresenter-timers"] .tm-time { [data-module="propresenter-timers"] .tm-time {
flex: none; flex: none;
font-size: var(--tm-time-size); font-size: 32px; /* fit() sets the real size per card */
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -113,14 +186,14 @@
white-space: nowrap; white-space: nowrap;
} }
/* Admin "Timer font: Monospace" — steadier digits as the numbers change. /* 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 { [data-module="propresenter-timers"] .tm-mono .tm-time {
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace; font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
letter-spacing: 0; letter-spacing: 0;
} }
[data-module="propresenter-timers"] .tm-state { [data-module="propresenter-timers"] .tm-status,
[data-module="propresenter-timers"] .tm-detail {
flex: none; flex: none;
max-width: 100%; max-width: 100%;
font-size: var(--tm-meta-size); font-size: var(--tm-meta-size);
@@ -132,102 +205,98 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-timers"] .tm-status[hidden],
[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; }
/* ── state looks ── */ [data-module="propresenter-timers"] .tm-detail {
font-weight: 600;
[data-module="propresenter-timers"] .tm-card.is-running { letter-spacing: 0.04em;
border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); text-transform: none;
background: color-mix(in srgb, var(--accent) 10%, transparent); opacity: 0.85;
}
[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card.is-complete {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
background: color-mix(in srgb, var(--warn) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); }
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); }
/* A solo tile whose timer was deleted in ProPresenter. */
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); }
/* Overrun: readable across the room even on a wall display. */
[data-module="propresenter-timers"] .tm-card.is-overrun {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 22%, transparent);
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
}
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); }
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state {
color: var(--danger);
font-weight: 800;
} }
@keyframes tm-overrun-pulse { /* A provider's status line carries its own tone ("ahead 1:20" in green). */
50% { background: color-mix(in srgb, var(--danger) 38%, transparent); } [data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); }
} [data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
/* ── the LTC card: visually its own thing ── */ /* Explanatory notes read as sentences, not badges. */
[data-module="propresenter-timers"] .tm-status.is-note {
[data-module="propresenter-timers"] .tm-card-ltc {
border-style: dashed;
}
[data-module="propresenter-timers"] .tm-card-ltc .tm-time {
font-size: var(--tm-ltc-size);
}
[data-module="propresenter-timers"] .tm-ltc-badge {
flex: none;
padding: 0.15em 0.55em;
border-radius: 5px;
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: var(--text);
font-size: 0.95em;
letter-spacing: 0.12em;
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal {
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
}
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state {
color: var(--warn);
animation: tm-nosignal-blink 1.6s steps(2) infinite;
}
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors,
stopping the clock is usually somebody doing their job. */
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); }
/* No timecode API on this ProPresenter: say so, quietly. */
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
text-transform: none; text-transform: none;
letter-spacing: 0.02em; letter-spacing: 0.02em;
white-space: normal; white-space: normal;
text-align: center; text-align: center;
} }
/* ── state looks (feedbacks) ── */
[data-module="propresenter-timers"] .tm-card.is-ok {
border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border));
background: color-mix(in srgb, var(--tm-ok) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); }
[data-module="propresenter-timers"] .tm-card.is-warn {
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
background: color-mix(in srgb, var(--tm-warn) 10%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); }
[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); }
/* The wall clock is information, not a state — plain text colour. */
[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); }
/* Overrun: readable across the room even on a wall display. */
[data-module="propresenter-timers"] .tm-card.is-danger {
border-color: var(--tm-danger);
background: color-mix(in srgb, var(--tm-danger) 22%, transparent);
}
[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-card.is-overrun {
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
}
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; }
@keyframes tm-overrun-pulse {
50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); }
}
/* A solo tile whose timer is no longer offered. */
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
/* ── the LTC card: visually its own thing ── */
[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; }
[data-module="propresenter-timers"] .tm-ltc-badge {
flex: none;
padding: 0.15em 0.55em;
border-radius: 5px;
background: color-mix(in srgb, var(--tm-ok) 24%, transparent);
color: var(--text);
font-size: 0.95em;
letter-spacing: 0.12em;
}
/* No signal: muted digits, a blinking warning line. */
[data-module="propresenter-timers"] .tm-card.is-nosignal {
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
}
[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status {
animation: tm-nosignal-blink 1.6s steps(2) infinite;
}
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ /* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item { [data-module="propresenter-timers"] .tm-menu-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 14px;
width: 100%; width: 100%;
padding: 6px 9px; padding: 6px 9px;
background: none; background: none;
@@ -241,16 +310,41 @@
white-space: nowrap; white-space: nowrap;
} }
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); } [data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
[data-module="propresenter-timers"] .tm-menu-item .check {
width: 14px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; }
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; } [data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
properties don't reach it — the shell's palette does.) */
[data-module="propresenter-timers"] .tm-switch {
flex: none;
position: relative;
width: 26px;
height: 14px;
border-radius: 7px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--muted) 25%, transparent);
transition: background 0.15s ease, border-color 0.15s ease;
}
[data-module="propresenter-timers"] .tm-switch::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text);
transition: transform 0.15s ease;
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
border-color: var(--accent);
background: var(--accent);
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
transform: translateX(12px);
background: var(--bg);
}
[data-module="propresenter-timers"] .tm-menu-note { [data-module="propresenter-timers"] .tm-menu-note {
padding: 6px 9px; padding: 6px 9px;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.3.0", "version": "1.4.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+184 -6
View File
@@ -94,6 +94,35 @@ body { overflow: auto; }
} }
.module-health.error { color: var(--danger); } .module-health.error { color: var(--danger); }
/* a module card's own status line ("polling timers on 10.3.11.20:1025",
"no ProPresenter host configured"): a readout, so it sits in a bordered
monospace box a shade lighter than the card; ok / connecting / error
show as the text (and border) colour, the box itself never changes */
.module-status {
display: inline-block;
max-width: 100%;
margin-top: 8px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11.5px;
line-height: 1.45;
color: var(--muted);
overflow-wrap: anywhere;
}
.module-status:empty { display: none; }
.module-status.ok { color: var(--text); }
.module-status.connecting {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
}
.module-status.error {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
}
.module-mount-error { .module-mount-error {
margin-top: 8px; margin-top: 8px;
padding: 8px 10px; padding: 8px 10px;
@@ -121,6 +150,10 @@ body { overflow: auto; }
border-radius: 9px; border-radius: 9px;
} }
.field-group-title { .field-group-title {
display: flex;
align-items: center;
gap: 7px;
position: relative; /* the ⓘ popover hangs off this row */
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@@ -128,8 +161,82 @@ body { overflow: auto; }
color: var(--muted); color: var(--muted);
margin-bottom: 10px; margin-bottom: 10px;
} }
.field-group-name { min-width: 0; }
.field-group .field:last-child { margin-bottom: 0; } .field-group .field:last-child { margin-bottom: 0; }
/* group help (configGroups[name].help): an ⓘ beside the title. Hover or
focus peeks at the text; a click pins it open until the next click. */
.group-info { display: inline-flex; align-items: center; }
.group-info-btn {
width: 16px;
height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--muted);
border-radius: 50%;
background: transparent;
color: var(--muted);
font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif;
text-transform: none;
letter-spacing: 0;
cursor: help;
}
.group-info-btn:hover,
.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); }
.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.group-info-pop {
display: none;
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 5;
max-width: min(440px, 100%);
padding: 8px 10px;
background: color-mix(in srgb, var(--text) 6%, var(--panel));
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
color: var(--text);
font-size: 12px;
font-weight: 400;
line-height: 1.5;
letter-spacing: normal;
text-transform: none;
white-space: normal;
cursor: auto;
}
/* keeps the popover open while the pointer crosses the gap down to it */
.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
.group-info:hover .group-info-pop,
.group-info-btn:focus-visible + .group-info-pop,
.group-info.is-open .group-info-pop { display: block; }
/* group toggle (configGroups[name].toggle): the named switch sits at the
right of the title row as the group's on/off; the body dims while it is
off but stays editable */
.field-group-toggle {
margin-left: auto;
display: inline-flex;
align-items: center;
flex: none;
}
.field-group-toggle .field {
margin: 0;
max-width: none;
font-size: 12px;
font-weight: 600;
letter-spacing: normal;
text-transform: none;
color: var(--text);
}
.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; }
.field-group-toggle .field.switch-field .switch-label { font-size: 12px; }
.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; }
.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; }
.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; }
/* collapsible groups (manifest configGroups[name].collapsed) */ /* collapsible groups (manifest configGroups[name].collapsed) */
details.field-group > summary.field-group-title { details.field-group > summary.field-group-title {
cursor: pointer; cursor: pointer;
@@ -160,18 +267,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
gap: 4px 16px; gap: 4px 16px;
} }
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } .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; } } @media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
.field-group-help, /* one line of guidance under a control (field `help`) */
.field-help { .field-help {
display: block; display: block;
font-size: 11.5px; font-size: 11.5px;
line-height: 1.45; line-height: 1.45;
color: var(--muted); color: var(--muted);
margin-top: 2px;
} }
.field-group-help { margin: -4px 0 8px; }
.field-help { margin-top: 2px; }
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s /* showWhen: a hidden dependent field must actually collapse — `.field`'s
@@ -190,12 +295,19 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
} }
.field.switch-field .switch-label { font-size: 13px; } .field.switch-field .switch-label { font-size: 13px; }
/* the card's one action row: status on the left, Configure / Apply on the right */
.module-actions { .module-actions {
display: flex; display: flex;
align-items: center; align-items: flex-end; /* the button stays bottom-right when a readout wraps */
gap: 10px; gap: 10px;
margin-top: 4px; margin-top: 10px;
} }
.module-name.is-toggle { cursor: pointer; }
.module-name.is-toggle:hover,
.module-name.is-toggle:focus-visible { color: var(--accent); outline: none; }
.module-actions .admin-status { flex: 1; margin-top: 0; }
.module-actions .module-status { flex: 0 1 auto; min-width: 0; margin-top: 0; }
.module-actions .btn { flex: none; margin-left: auto; }
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; } .admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
.admin-status.error { color: var(--danger); } .admin-status.error { color: var(--danger); }
@@ -312,3 +424,69 @@ a.btn { text-decoration: none; }
.catalog-title { display: flex; align-items: center; gap: 8px; } .catalog-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-desc { margin: 4px 0 2px; }
.catalog-row .module-health { margin-top: 2px; } .catalog-row .module-health { margin-top: 2px; }
/* ── theme ──────────────────────────────────────────────────────────── */
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
accent as a status dot) and the name; the current one wears the accent */
.theme-swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.theme-swatch {
display: flex;
flex-direction: column;
gap: 7px;
padding: 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font: inherit;
font-size: 12px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.theme-swatch:hover { border-color: var(--muted); }
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-preview {
position: relative;
display: block;
height: 46px;
border-radius: 6px;
background: var(--sw-bg);
border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent));
overflow: hidden;
}
.theme-preview::before {
content: "";
position: absolute;
left: 8px; right: 8px; top: 10px; bottom: 8px;
border-radius: 4px;
background: var(--sw-panel);
border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent));
}
.theme-preview::after {
content: "";
position: absolute;
left: 14px; top: 16px;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--sw-accent);
box-shadow: 0 0 6px var(--sw-accent);
}
.theme-name { display: flex; align-items: center; gap: 6px; }
.theme-swatch.is-current .theme-name::after {
content: "current";
margin-left: auto;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
#theme-status { margin-top: 4px; }
+280 -19
View File
@@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status');
const updateBtn = document.getElementById('update-btn'); const updateBtn = document.getElementById('update-btn');
const updateChanges = document.getElementById('update-changes'); const updateChanges = document.getElementById('update-changes');
const shellVersionEl = document.getElementById('shell-version'); const shellVersionEl = document.getElementById('shell-version');
const themeSection = document.getElementById('theme-section');
const themeSwatches = document.getElementById('theme-swatches');
const themeStatus = document.getElementById('theme-status');
/** The shell version the page loaded with — the update flow watches it change. */ /** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = ''; let shellVersion = '';
@@ -58,6 +61,7 @@ function showLogin() {
availableSection.hidden = true; availableSection.hidden = true;
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true; updateCard.hidden = true;
themeSection.hidden = true;
passcodeInput.focus(); passcodeInput.focus();
} }
@@ -277,6 +281,99 @@ function groupHasValues(fields, mod) {
}); });
} }
/* ── group title row: the ⓘ and the group toggle ────────────────────── */
let groupInfoSeq = 0;
function closeGroupInfos(except) {
for (const open of document.querySelectorAll('.group-info.is-open')) {
if (open === except) continue;
open.classList.remove('is-open');
open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false');
}
}
/**
* configGroups[name].help as an ⓘ beside the group title: click toggles a
* small popover with the text (hover and keyboard focus show it too), so a
* group can carry a paragraph without pushing its fields down the page. A
* real button, labelled, so it works from the keyboard; inside a <summary>
* it must never fold the group.
*/
function groupInfo(groupName, text) {
const wrap = document.createElement('span');
wrap.className = 'group-info';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'group-info-btn';
btn.textContent = 'i';
btn.setAttribute('aria-label', `About ${groupName}`);
btn.setAttribute('aria-expanded', 'false');
const pop = document.createElement('span');
pop.className = 'group-info-pop';
pop.id = `group-info-${++groupInfoSeq}`;
pop.setAttribute('role', 'note');
pop.textContent = text;
btn.setAttribute('aria-describedby', pop.id);
wrap.append(btn, pop);
const setOpen = (open) => {
wrap.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
};
btn.addEventListener('click', (e) => {
e.preventDefault(); // a click in a <summary> would otherwise fold the group
e.stopPropagation();
const open = !wrap.classList.contains('is-open');
closeGroupInfos(wrap);
setOpen(open);
});
pop.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
});
wrap.addEventListener('keydown', (e) => {
// Enter / Space activate the button (a click); they must not reach a
// <summary> above as well. Escape closes and hands focus back.
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
if (e.key === 'Escape' && wrap.classList.contains('is-open')) {
e.stopPropagation();
setOpen(false);
btn.focus();
}
});
return wrap;
}
// A click anywhere else, or Escape, closes an open popover.
document.addEventListener('pointerdown', (e) => {
if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeGroupInfos();
});
/**
* configGroups[name].toggle: the named switch/boolean field is the group's
* on/off. It moves into the title row, right-aligned, and the body dims
* while it is off (fields stay editable). The field itself is untouched —
* it is still read on save and still drives showWhen dependents.
*/
function mountGroupToggle(group, field, control) {
const slot = document.createElement('span');
slot.className = 'field-group-toggle';
slot.appendChild(field);
// Inside a <summary>, flipping the switch must not fold the group.
slot.addEventListener('click', (e) => e.stopPropagation());
slot.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
});
group.title.appendChild(slot);
group.el.classList.add('has-toggle');
const sync = () => group.el.classList.toggle('is-off', !control.checked);
control.addEventListener('change', sync);
sync();
}
function buildModuleCard(mod) { function buildModuleCard(mod) {
const card = document.createElement('section'); const card = document.createElement('section');
card.className = 'admin-card'; card.className = 'admin-card';
@@ -351,8 +448,11 @@ function buildModuleCard(mod) {
desc.textContent = mod.description; desc.textContent = mod.description;
card.appendChild(desc); card.appendChild(desc);
// The module's own words about its upstream ("polling timers on
// 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box
// and the state (ok / connecting / error) is its text colour.
const healthEl = document.createElement('div'); const healthEl = document.createElement('div');
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); healthEl.className = 'module-status ' + healthClass(mod);
healthEl.textContent = healthText(mod); healthEl.textContent = healthText(mod);
card.appendChild(healthEl); card.appendChild(healthEl);
@@ -395,7 +495,9 @@ function buildModuleCard(mod) {
const collapsibles = []; // { el, meta, fields } groups that fold const collapsibles = []; // { el, meta, fields } groups that fold
// Fields render in schema order; a `group` starts a labeled subsection // Fields render in schema order; a `group` starts a labeled subsection
// and consecutive same-group fields share it. The manifest's optional // and consecutive same-group fields share it. The manifest's optional
// `configGroups[name]` adds { collapsed, columns, help } for that group. // `configGroups[name]` adds { collapsed, columns, help, toggle } for
// that group: help is an ⓘ beside the title, toggle names the switch
// that is the group's on/off and lives in the title row.
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
let groupName = null; let groupName = null;
let container = form; let container = form;
@@ -412,7 +514,11 @@ function buildModuleCard(mod) {
group.className = 'field-group'; group.className = 'field-group';
const title = document.createElement(folds ? 'summary' : 'div'); const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title'; title.className = 'field-group-title';
title.textContent = g; const titleName = document.createElement('span');
titleName.className = 'field-group-name';
titleName.textContent = g;
title.appendChild(titleName);
if (meta.help) title.appendChild(groupInfo(g, String(meta.help)));
group.appendChild(title); group.appendChild(title);
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'field-group-body'; body.className = 'field-group-body';
@@ -421,16 +527,16 @@ function buildModuleCard(mod) {
body.classList.add('is-columns'); body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols)); 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); group.appendChild(body);
form.appendChild(group); form.appendChild(group);
container = body; container = body;
currentGroup = { el: group, meta, fields: [] }; currentGroup = {
el: group,
title,
meta,
fields: [],
toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '',
};
if (folds) collapsibles.push(currentGroup); if (folds) collapsibles.push(currentGroup);
} else { } else {
container = form; container = form;
@@ -444,8 +550,14 @@ function buildModuleCard(mod) {
else if (refetch) reloaders.push(() => refetch()); else if (refetch) reloaders.push(() => refetch());
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
if (currentGroup) currentGroup.fields.push({ key, spec }); if (currentGroup) currentGroup.fields.push({ key, spec });
if (currentGroup && control && key === currentGroup.toggleKey) {
// The group's on/off: title row, not the body (never both). A
// toggle naming a non-boolean field is ignored and renders normally.
mountGroupToggle(currentGroup, field, control);
} else {
container.appendChild(field); container.appendChild(field);
} }
}
// A field with showWhen:"K" is visible only while K's toggle is on. // A field with showWhen:"K" is visible only while K's toggle is on.
for (const dep of dependents) { for (const dep of dependents) {
const ctl = controls.get(dep.when); const ctl = controls.get(dep.when);
@@ -473,26 +585,52 @@ function buildModuleCard(mod) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'module-actions'; actions.className = 'module-actions';
const save = document.createElement('button'); const save = document.createElement('button');
save.className = 'btn primary';
save.textContent = 'Save & re-init';
const status = document.createElement('span'); const status = document.createElement('span');
status.className = 'admin-status'; status.className = 'admin-status';
actions.append(save, status); actions.append(status, save);
form.appendChild(actions); // A card is a status line until someone wants to change something: the
// form starts folded, and the row at the bottom holds one button that
// does both jobs — "Configure" opens the form and becomes "Apply", which
// saves without closing it. The name is the other way in and out.
card.appendChild(form); card.appendChild(form);
card.appendChild(actions);
const openKey = 'proddash:admin:open:' + mod.id;
const setOpen = (open) => {
form.hidden = !open;
status.hidden = !open;
save.textContent = open ? 'Apply' : 'Configure';
save.className = open ? 'btn primary' : 'btn';
// Folded, the readout shares the bottom row with Configure instead of
// taking a line of its own; open, it goes back above the form.
if (open) card.insertBefore(healthEl, mountError);
else actions.insertBefore(healthEl, status);
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ }
};
let wasOpen = false;
try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ }
setOpen(wasOpen);
name.classList.add('is-toggle');
name.title = 'Show or hide this module\'s settings';
name.tabIndex = 0;
name.setAttribute('role', 'button');
name.addEventListener('click', () => setOpen(form.hidden));
name.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); }
});
save.addEventListener('click', async () => { save.addEventListener('click', async () => {
if (form.hidden) return setOpen(true);
const config = {}; const config = {};
for (const [key, read] of readers) config[key] = read(); for (const [key, read] of readers) config[key] = read();
save.disabled = true; save.disabled = true;
status.textContent = 'Saving…'; status.textContent = 'Applying…';
status.className = 'admin-status'; status.className = 'admin-status';
try { try {
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, { await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ config }), body: JSON.stringify({ config }),
}); });
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.'; status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.';
status.className = 'admin-status ok'; status.className = 'admin-status ok';
refreshStatus(); refreshStatus();
// What the module can list may have changed (credentials just // What the module can list may have changed (credentials just
@@ -531,7 +669,7 @@ async function refreshStatus() {
if (!els) continue; if (!els) continue;
els.dot.className = 'dot ' + healthClass(mod); els.dot.className = 'dot ' + healthClass(mod);
els.healthEl.textContent = healthText(mod); els.healthEl.textContent = healthText(mod);
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); els.healthEl.className = 'module-status ' + healthClass(mod);
if (!mod.incompatible) { if (!mod.incompatible) {
els.mountError.hidden = !mod.mountError; els.mountError.hidden = !mod.mountError;
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
@@ -543,7 +681,7 @@ async function refreshStatus() {
} }
} }
/* ── named layouts ──────────────────────────────────────────────────── */ /* ── saved layouts ──────────────────────────────────────────────────── */
async function loadLayouts() { async function loadLayouts() {
let body; let body;
@@ -559,7 +697,7 @@ async function loadLayouts() {
if (!layouts.length) { if (!layouts.length) {
const none = document.createElement('div'); const none = document.createElement('div');
none.className = 'layouts-empty'; none.className = 'layouts-empty';
none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.'; none.textContent = 'No layouts saved yet. Save one from the dashboard’s Layout menu.';
layoutsList.appendChild(none); layoutsList.appendChild(none);
return; return;
} }
@@ -640,10 +778,133 @@ async function loadState() {
for (const mod of state.modules || []) { for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod)); modulesList.appendChild(buildModuleCard(mod));
} }
loadTheme();
loadLayouts(); loadLayouts();
loadCatalog(false); loadCatalog(false);
} }
/* ── theme ──────────────────────────────────────────────────────────── */
/* The dashboard palette is a server-wide choice. The server lists the
presets (GET /api/theme — public, dashboards need it too) and the admin
page shows them as swatches; picking one POSTs it, applies it here at
once, and the server's `theme` shell event switches every open dashboard
— and any other admin page — live. The palettes themselves are
html[data-theme="…"] variable sets in the shell's style.css, which this
page shares, so the admin page follows its own choice. */
const LS_THEME = 'proddash:theme';
let themeList = [];
let currentTheme = '';
function applyTheme(id) {
const theme = String(id || '');
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
}
// The last-known theme first (shared with the dashboard, same origin), so
// the page doesn't flash the default palette before the server answers.
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
function themeName(id) {
return themeList.find((t) => t.id === id)?.name || id;
}
function renderThemes() {
themeSwatches.innerHTML = '';
for (const t of themeList) {
const current = t.id === currentTheme;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-swatch' + (current ? ' is-current' : '');
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', current ? 'true' : 'false');
btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`;
const preview = document.createElement('span');
preview.className = 'theme-preview';
preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000'));
preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222'));
preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff'));
const name = document.createElement('span');
name.className = 'theme-name';
name.textContent = t.name;
btn.append(preview, name);
btn.addEventListener('click', () => chooseTheme(t.id));
themeSwatches.appendChild(btn);
}
}
/**
* Fetch the server's theme and list. Applied to this page either way (the
* endpoint is public, so even the login card wears the right palette); the
* swatch section itself is only revealed for an unlocked page.
*/
async function loadTheme(reveal = true) {
let body;
try {
body = await api('/api/theme');
} catch {
if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer
return;
}
themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : [];
currentTheme = String(body.theme || '');
applyTheme(currentTheme);
renderThemes();
if (reveal) themeSection.hidden = !themeList.length;
}
loadTheme(false);
async function chooseTheme(id) {
if (id === currentTheme) return;
const previous = currentTheme;
currentTheme = id;
applyTheme(id); // at once, here — the server's event confirms it everywhere else
renderThemes();
themeStatus.textContent = '';
themeStatus.className = 'admin-status';
try {
await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) });
themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`;
themeStatus.className = 'admin-status ok';
} catch (e) {
currentTheme = previous;
applyTheme(previous);
renderThemes();
themeStatus.textContent = e.message;
themeStatus.className = 'admin-status error';
if (e.authRequired) showLogin();
}
}
// Follow a theme change made elsewhere (another admin page) live.
let themeEvents = null;
let themeEventsRetry = null;
function subscribeThemeEvents() {
try { themeEvents?.close(); } catch { /* not open */ }
themeEvents = new EventSource('/api/events');
themeEvents.addEventListener('theme', (e) => {
let theme = '';
try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; }
if (!theme || theme === currentTheme) return;
currentTheme = theme;
applyTheme(theme);
renderThemes();
});
themeEvents.onerror = () => {
if (themeEvents.readyState === EventSource.CLOSED) {
clearTimeout(themeEventsRetry);
themeEventsRetry = setTimeout(subscribeThemeEvents, 4000);
}
};
}
subscribeThemeEvents();
/* ── the repo: available modules, module updates, shell update ──────── */ /* ── the repo: available modules, module updates, shell update ──────── */
function fmtTime(ms) { function fmtTime(ms) {
+7 -9
View File
@@ -39,11 +39,14 @@
<div class="admin-status" id="update-status"></div> <div class="admin-status" id="update-status"></div>
</section> </section>
<section id="theme-section" hidden>
<h1 class="admin-h1">Theme</h1>
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
</section>
<section id="modules-section" hidden> <section id="modules-section" hidden>
<h1 class="admin-h1">Modules</h1> <h1 class="admin-h1">Modules</h1>
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
live in each tile's gear menu instead.</p>
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at <p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p> <code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
<div id="modules-list"></div> <div id="modules-list"></div>
@@ -51,9 +54,6 @@
<section id="available-section" hidden> <section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1> <h1 class="admin-h1">Available modules</h1>
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
newer ProdDash can't be installed until the shell is updated.</p>
<div class="catalog-toolbar"> <div class="catalog-toolbar">
<button class="btn small" id="catalog-refresh">Check again</button> <button class="btn small" id="catalog-refresh">Check again</button>
<span class="admin-status" id="catalog-status"></span> <span class="admin-status" id="catalog-status"></span>
@@ -62,9 +62,7 @@
</section> </section>
<section id="layouts-section" hidden> <section id="layouts-section" hidden>
<h1 class="admin-h1">Named layouts</h1> <h1 class="admin-h1">Saved layouts</h1>
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
copies it into that browser; deleting one here does not touch any browser's current layout.</p>
<div id="layouts-list"></div> <div id="layouts-list"></div>
</section> </section>
</main> </main>
+2 -3
View File
@@ -15,8 +15,7 @@
</head> </head>
<body> <body>
<header id="topbar"> <header id="topbar">
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below --> <span id="brand">Prod<span class="accent">Dash</span></span>
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
<span class="spacer"></span> <span class="spacer"></span>
@@ -51,7 +50,7 @@
</a> </a>
</header> </header>
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button> <button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
<main id="gridwrap"> <main id="gridwrap">
<div id="grid"> <div id="grid">
+382 -98
View File
@@ -71,12 +71,34 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
/* ── dropdown menus ─────────────────────────────────────────────────── */ /* ── dropdown menus ─────────────────────────────────────────────────── */
/* A menu opens the moment its button is clicked, drawn from what the page
already knows; whatever its rebuild has to fetch is slotted in when it
lands. It used to open only once the rebuild had finished, which made the
+ picker hostage to a network round trip — one that never completes when
the tiles' streams hold every connection the browser allows (see the
shared-stream pool below). rebuild(menu, stillOpen) may be async;
stillOpen() says whether the open it was started for is still current,
so a late fetch can't repaint a menu that has since closed or reopened. */
function wireDropdown(btnId, menuId, rebuild) { function wireDropdown(btnId, menuId, rebuild) {
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId); const menu = document.getElementById(menuId);
btn.addEventListener('click', async () => { let generation = 0;
if (menu.hidden) await rebuild(menu); btn.addEventListener('click', () => {
menu.hidden = !menu.hidden; if (!menu.hidden) {
menu.hidden = true;
return;
}
const gen = ++generation;
const stillOpen = () => gen === generation && !menu.hidden;
menu.hidden = false;
// Whatever the rebuild does, the menu is on screen; a failure (now or
// later) leaves its previous contents rather than an invisible menu.
const failed = (err) => console.error(`[shell] ${menuId} could not be rebuilt:`, err);
try {
Promise.resolve(rebuild(menu, stillOpen)).catch(failed);
} catch (err) {
failed(err);
}
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) { if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
@@ -106,11 +128,22 @@ function menuItem(label, onClick, { sub = '', danger = false } = {}) {
let registry = new Map(); // module id -> manifest from GET /api/modules let registry = new Map(); // module id -> manifest from GET /api/modules
/** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything
awaiting it. */
function fetchWithTimeout(url, ms, opts = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), ms);
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(timer));
}
/** Refresh the enabled-module list. On failure the last known list is kept /** Refresh the enabled-module list. On failure the last known list is kept
(the server may just be restarting mid-service). Returns true on success. */ (the server may just be restarting mid-service). Returns true on success. */
async function loadRegistry() { async function loadRegistry() {
try { try {
const res = await fetch('/api/modules'); // Slow modules' tiles() are cut off server-side at 2 s, so a healthy
// answer is well inside this.
const res = await fetchWithTimeout('/api/modules', 8000);
if (!res.ok) throw new Error('modules ' + res.status); if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json(); const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m])); registry = new Map((body.modules || []).map((m) => [m.id, m]));
@@ -288,11 +321,24 @@ function buildTile(tile) {
notch.title = 'Hide / show the title bar'; notch.title = 'Hide / show the title bar';
notch.addEventListener('click', (e) => { notch.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
// In fullscreen the bars are all folded away for watching; the notch
// peeks one open for a moment without touching what this tile saved.
if (document.body.classList.contains('fullscreen')) {
peekHead(tile, el, !el.classList.contains('head-peek'));
return;
}
tile.headHidden = !tile.headHidden; tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden); el.classList.toggle('head-hidden', tile.headHidden);
saveLayout(); saveLayout();
}); });
// A peeked title bar stays while the pointer is on its tile, and starts
// counting down as soon as it leaves.
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
el.addEventListener('pointerleave', () => {
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
});
el.append(head, body, resize, resizeLeft, resizeBottom, notch); el.append(head, body, resize, resizeLeft, resizeBottom, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden)); el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el); grid.appendChild(el);
@@ -518,13 +564,22 @@ function notifyResize(tile) {
/* ── seam dividers between adjacent tiles ───────────────────────────── */ /* ── seam dividers between adjacent tiles ───────────────────────────── */
/* Wherever two tiles share a vertical edge, a divider sits on the seam. /* Wherever two tiles share a vertical edge, a divider sits on the seam.
Dragging its pill moves the shared edge, trading width between the two Dragging one moves that column boundary wherever it appears: every pair of
tiles — their combined span never changes, so nothing else can collide tiles meeting at the same x, in every row, moves with it, so a column of
except via the explicit check below (a third tile can sit behind the tiles keeps its edge as one line rather than drifting row by row. Hovering
seam for part of its height). */ a seam lights up the others that travel with it.
Each pair trades width within its own span — the pair's combined span never
changes — so nothing else can collide except via the explicit check below
(a third tile can sit behind the seam for part of its height). A row where
one tile simply spans the boundary has no seam there and stays put. */
/** Live dividers, so a drag can reposition all of them as the tiles move. */
const dividers = [];
function rebuildDividers() { function rebuildDividers() {
grid.querySelectorAll('.tile-divider').forEach((el) => el.remove()); grid.querySelectorAll('.tile-divider').forEach((el) => el.remove());
dividers.length = 0;
for (const L of tiles) { for (const L of tiles) {
for (const R of tiles) { for (const R of tiles) {
if (L === R || L.x + L.w !== R.x) continue; if (L === R || L.x + L.w !== R.x) continue;
@@ -536,10 +591,24 @@ function rebuildDividers() {
} }
} }
/** Every pair of tiles that meets at this column boundary, in any row. */
function seamPairsAt(boundary) {
const pairs = [];
for (const L of tiles) {
if (L.x + L.w !== boundary) continue;
for (const R of tiles) {
if (R === L || R.x !== boundary) continue;
if (Math.min(L.y + L.h, R.y + R.h) <= Math.max(L.y, R.y)) continue;
pairs.push({ L, R });
}
}
return pairs;
}
function buildDivider(L, R, top, bottom) { function buildDivider(L, R, top, bottom) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'tile-divider'; el.className = 'tile-divider';
el.title = 'Drag to resize'; el.title = 'Drag to resize this column';
const place = () => { const place = () => {
const m = cellMetrics(); const m = cellMetrics();
@@ -550,43 +619,72 @@ function buildDivider(L, R, top, bottom) {
}; };
place(); place();
const boundary = () => L.x + L.w;
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
// Before anything is dragged, show which seams travel with this one.
el.addEventListener('pointerenter', () => {
if (document.body.classList.contains('fullscreen')) return;
for (const other of groupEls()) other.classList.add('linked');
});
el.addEventListener('pointerleave', unlink);
el.addEventListener('pointerdown', (ev) => { el.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return; if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault(); ev.preventDefault();
closeSettingsPopovers(); closeSettingsPopovers();
el.setPointerCapture(ev.pointerId); el.setPointerCapture(ev.pointerId);
el.classList.add('active');
const m = cellMetrics(); const m = cellMetrics();
const sx = ev.clientX; const sx = ev.clientX;
const startBoundary = L.x + L.w; const startBoundary = boundary();
const rightEdge = R.x + R.w; // fixed for the whole drag // Every row that has a seam here moves with it. Each pair's outer edges
const minB = L.x + minSizeOf(L).w; // are fixed for the whole drag, so each row only trades width internally.
const maxB = rightEdge - minSizeOf(R).w; const parts = seamPairsAt(startBoundary).map(({ L: a, R: b }) => ({
L: a, R: b, leftX: a.x, rightEdge: b.x + b.w,
}));
if (!parts.length) return;
const moving = parts.flatMap((p) => [p.L.id, p.R.id]);
// The whole column stops at whichever row runs out of room first.
const minB = Math.max(...parts.map((p) => p.leftX + minSizeOf(p.L).w));
const maxB = Math.min(...parts.map((p) => p.rightEdge - minSizeOf(p.R).w));
const group = groupEls();
for (const other of group) other.classList.add('active');
let changed = false; let changed = false;
const onMove = (e) => { const onMove = (e) => {
const b = Math.max(minB, Math.min(maxB, const b = Math.max(minB, Math.min(maxB,
startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap)))); startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap))));
if (b === L.x + L.w) return; if (b === boundary()) return;
const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h }; const next = parts.map((p) => ({
const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h }; p,
if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return; candL: { x: p.leftX, y: p.L.y, w: b - p.leftX, h: p.L.h },
L.w = candL.w; candR: { x: b, y: p.R.y, w: p.rightEdge - b, h: p.R.h },
R.x = candR.x; }));
R.w = candR.w; // All rows move or none do — a column edge that bends is worse than one
applyRect(L); // that won't budge.
applyRect(R); if (next.some(({ candL, candR }) =>
notifyResize(L); !isFreeExcept(candL, moving) || !isFreeExcept(candR, moving))) return;
notifyResize(R); for (const { p, candL, candR } of next) {
place(); p.L.w = candL.w;
p.R.x = candR.x;
p.R.w = candR.w;
applyRect(p.L);
applyRect(p.R);
notifyResize(p.L);
notifyResize(p.R);
}
for (const d of dividers) d.place();
changed = true; changed = true;
}; };
const onUp = () => { const onUp = () => {
el.removeEventListener('pointermove', onMove); el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp); el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp); el.removeEventListener('pointercancel', onUp);
el.classList.remove('active'); for (const other of group) other.classList.remove('active');
unlink();
if (changed) saveLayout(); if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed rebuildDividers(); // adjacencies may have changed
}; };
@@ -595,6 +693,7 @@ function buildDivider(L, R, top, bottom) {
el.addEventListener('pointercancel', onUp); el.addEventListener('pointercancel', onUp);
}); });
dividers.push({ el, place, boundary });
grid.appendChild(el); grid.appendChild(el);
} }
@@ -634,6 +733,7 @@ function addTile(moduleId, entry = null) {
function removeTile(id) { function removeTile(id) {
const idx = tiles.findIndex((t) => t.id === id); const idx = tiles.findIndex((t) => t.id === id);
if (idx < 0) return; if (idx < 0) return;
clearPeekTimer(id);
unmountModule(tiles[idx]); unmountModule(tiles[idx]);
tiles.splice(idx, 1); tiles.splice(idx, 1);
const entry = tileEls.get(id); const entry = tileEls.get(id);
@@ -686,6 +786,84 @@ function tileMessage(entry, text) {
entry.body.appendChild(msg); entry.body.appendChild(msg);
} }
/* ── shared event streams ───────────────────────────────────────────── */
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
EventSource holds one of them for as long as it lives. One stream per
tile plus the shell's own /api/events reaches that ceiling at five or six
tiles, and from then on every further request — the + picker's module
list, a new tile's /state, its stream — sits in the browser's queue
waiting for a connection that is never handed back. So tiles that ask
for the same stream share one connection: the pool keeps one EventSource
per URL, fans its events out to every subscriber, and reconnects it (on
the same 3-second timer the per-tile version used) while anyone is still
listening. A tile joining an already-open stream is told `open` straight
away, so its backfill runs exactly as if the connection were its own. */
const ssePool = new Map(); // url -> shared source
function sseSubscribe(url, handlers = {}) {
let shared = ssePool.get(url);
if (!shared) {
shared = { subs: new Set(), es: null, retryTimer: null, names: new Set() };
const fanOut = (kind, e) => {
for (const sub of [...shared.subs]) {
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
}
};
shared.listen = (name) => shared.es.addEventListener(name, (e) => fanOut('event', e));
shared.connect = () => {
clearTimeout(shared.retryTimer);
shared.retryTimer = null;
try { shared.es?.close(); } catch { /* already closed */ }
const es = new EventSource(url);
shared.es = es;
es.onopen = (e) => fanOut('open', e);
es.onmessage = (e) => fanOut('message', e);
es.onerror = (e) => {
fanOut('error', e);
// EventSource retries transient drops itself but gives up for good on
// a completed non-SSE response (a 502 while the upstream is down).
if (es.readyState === EventSource.CLOSED && shared.subs.size) {
clearTimeout(shared.retryTimer);
shared.retryTimer = setTimeout(shared.connect, 3000);
}
};
for (const name of shared.names) shared.listen(name);
};
ssePool.set(url, shared);
}
const sub = { handlers };
shared.subs.add(sub);
for (const name of Object.keys(handlers.events || {})) {
if (shared.names.has(name)) continue;
shared.names.add(name);
if (shared.es) shared.listen(name);
}
if (!shared.es || shared.es.readyState === EventSource.CLOSED) {
// first subscriber, or the stream had given up while nobody listened
shared.connect();
} else if (shared.es.readyState === EventSource.OPEN) {
// Joining mid-flight: report the open the way a fresh connection would
// have, a moment later, so the caller's own setup finishes first.
setTimeout(() => {
if (!shared.subs.has(sub)) return;
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
}, 0);
}
return {
close() {
if (!shared.subs.delete(sub) || shared.subs.size) return;
clearTimeout(shared.retryTimer);
try { shared.es?.close(); } catch { /* already closed */ }
ssePool.delete(url);
},
};
}
/** /**
* The API handed to each module instance — the whole surface a module may * The API handed to each module instance — the whole surface a module may
* touch outside its root element. See docs/MODULE-GUIDE.md. * touch outside its root element. See docs/MODULE-GUIDE.md.
@@ -731,40 +909,17 @@ function buildModuleApi(tile, man, entry) {
}, },
/** /**
* EventSource scoped the same way, with auto-reconnect: EventSource * EventSource scoped the same way, with auto-reconnect: a stream that
* retries transient drops itself but gives up for good when a retry gets * gives up (a 502 while the upstream is down) is recreated on a timer
* a completed non-SSE response (a 502 while the upstream is down), so a * until it works again. Tiles asking for the same stream share one
* closed stream is recreated on a timer until it works again. * connection — see the pool above; a module sees no difference.
* handlers: { open(e), error(e), message(e), events: { name: fn } } * handlers: { open(e), error(e), message(e), events: { name: fn } }
*/ */
sse(subPath, handlers = {}) { sse(subPath, handlers = {}) {
const url = '/api/modules/' + man.id + subPath; const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
let es = null;
let retryTimer = null;
let closed = false;
const connect = () => {
if (closed) return;
try { es?.close(); } catch { /* already closed */ }
es = new EventSource(url);
if (handlers.open) es.onopen = handlers.open;
if (handlers.message) es.onmessage = handlers.message;
for (const [name, fn] of Object.entries(handlers.events || {})) {
es.addEventListener(name, fn);
}
es.onerror = (e) => {
try { handlers.error?.(e); } catch { /* module's problem */ }
if (es.readyState === EventSource.CLOSED) {
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, 3000);
}
};
};
connect();
const handle = { const handle = {
close() { close() {
closed = true; shared.close();
clearTimeout(retryTimer);
try { es?.close(); } catch { /* already closed */ }
sseHandles.delete(handle); sseHandles.delete(handle);
}, },
}; };
@@ -928,7 +1083,10 @@ function colorHex(value, fallback) {
} }
function closeSettingsPopovers() { function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => p.remove()); document.querySelectorAll('.tile-settings').forEach((p) => {
p._cleanup?.();
p.remove();
});
} }
function toggleSettingsPopover(tile, el) { function toggleSettingsPopover(tile, el) {
@@ -942,6 +1100,34 @@ function toggleSettingsPopover(tile, el) {
pop.className = 'tile-settings'; pop.className = 'tile-settings';
const inputs = new Map(); const inputs = new Map();
/* These are display choices — a text size, a filter, a colour — so they
apply as they are changed and save with the layout. There is nothing to
confirm, so there is no Apply. Typing is debounced because applying
means remounting the module, which shouldn't happen per keystroke. */
let applyTimer = null;
const commit = (delay) => {
clearTimeout(applyTimer);
applyTimer = setTimeout(() => {
applyTimer = null;
// Only when something really moved: a text field fires `input` and then
// `change` on the way out, and a module shouldn't be torn down twice
// over for one edit.
let changed = false;
for (const [key, read] of inputs) {
const value = read();
if (tile.settings[key] === value) continue;
tile.settings[key] = value;
changed = true;
}
if (!changed) return;
saveLayout();
remountTile(tile);
}, delay);
};
const live = (input, event = 'change', delay = 0) => {
input.addEventListener(event, () => commit(delay));
};
// Fields render in schema order; a field's `group` starts a labeled // Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the manifest's // subsection that consecutive same-group fields share, and the manifest's
// optional `instanceGroups[name]` adds { collapsed, columns, help } — // optional `instanceGroups[name]` adds { collapsed, columns, help } —
@@ -990,15 +1176,28 @@ function toggleSettingsPopover(tile, el) {
} }
} }
const isSwitch = type === 'boolean' || type === 'switch';
const field = document.createElement('label'); const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : ''); field.className = 'field' + (isSwitch ? ' check' : '');
if (type === 'boolean') { if (isSwitch) {
// A switch: on is shown/enabled, off is hidden/disabled — the label
// names what it turns on. A real checkbox stays underneath (keyboard,
// label clicks and `change` all keep working); the track is the look.
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = label;
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.checked = Boolean(current); input.checked = Boolean(current);
field.append(input, document.createTextNode(label)); const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
field.append(text, toggle);
inputs.set(key, () => input.checked); inputs.set(key, () => input.checked);
live(input);
} else if (type === 'select' && Array.isArray(spec.options)) { } else if (type === 'select' && Array.isArray(spec.options)) {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
@@ -1012,6 +1211,7 @@ function toggleSettingsPopover(tile, el) {
select.value = String(current ?? ''); select.value = String(current ?? '');
field.append(span, select); field.append(span, select);
inputs.set(key, () => select.value); inputs.set(key, () => select.value);
live(select);
} else if (type === 'color') { } else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string. // A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field'); field.classList.add('color-field');
@@ -1022,6 +1222,7 @@ function toggleSettingsPopover(tile, el) {
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a')); input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
field.append(span, input); field.append(span, input);
inputs.set(key, () => input.value); inputs.set(key, () => input.value);
live(input, 'input', 150); // dragging the picker fires continuously
} else { } else {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
@@ -1030,6 +1231,8 @@ function toggleSettingsPopover(tile, el) {
input.value = current === undefined || current === null ? '' : String(current); input.value = current === undefined || current === null ? '' : String(current);
field.append(span, input); field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
live(input, 'input', 400); // mid-word is not the moment to remount
live(input, 'change'); // …but blur or Enter is
} }
if (spec?.help) { if (spec?.help) {
const help = document.createElement('small'); const help = document.createElement('small');
@@ -1040,26 +1243,17 @@ function toggleSettingsPopover(tile, el) {
container.appendChild(field); container.appendChild(field);
} }
const actions = document.createElement('div'); // Nothing to press: click away, press Escape, or click the gear again.
actions.className = 'actions'; // (The gear's own click stops propagating, so it toggles rather than
actions.style.display = 'flex'; // closing and reopening.)
actions.style.justifyContent = 'flex-end'; const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
actions.style.gap = '8px'; const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
const cancel = document.createElement('button'); pop._cleanup = () => {
cancel.className = 'btn'; document.removeEventListener('click', closeOnClick);
cancel.textContent = 'Cancel'; document.removeEventListener('keydown', closeOnEscape);
cancel.addEventListener('click', () => pop.remove()); };
const apply = document.createElement('button'); document.addEventListener('click', closeOnClick);
apply.className = 'btn primary'; document.addEventListener('keydown', closeOnEscape);
apply.textContent = 'Apply';
apply.addEventListener('click', () => {
for (const [key, read] of inputs) tile.settings[key] = read();
pop.remove();
saveLayout();
remountTile(tile);
});
actions.append(cancel, apply);
pop.appendChild(actions);
pop.addEventListener('pointerdown', (e) => e.stopPropagation()); pop.addEventListener('pointerdown', (e) => e.stopPropagation());
el.appendChild(pop); el.appendChild(pop);
@@ -1088,8 +1282,21 @@ function renderLayout(list) {
/* ── menus ──────────────────────────────────────────────────────────── */ /* ── menus ──────────────────────────────────────────────────────────── */
wireDropdown('add-btn', 'add-menu', async (menu) => { /* What the + picker shows, as a string: repainting a menu the user is
await loadRegistry(); already pointing at is only worth it when something in it changed. */
function addMenuSignature() {
return JSON.stringify([...registry.values()].map((m) => [m.id, m.name, m.description, m.tiles]));
}
wireDropdown('add-btn', 'add-menu', async (menu, stillOpen) => {
// Painted from the list already in hand, then refreshed: a module's tile
// list is live data, and Admin may have enabled or disabled something.
const shown = addMenuSignature();
renderAddMenu(menu);
if (await loadRegistry() && stillOpen() && addMenuSignature() !== shown) renderAddMenu(menu);
});
function renderAddMenu(menu) {
menu.innerHTML = ''; menu.innerHTML = '';
const manifests = [...registry.values()]; const manifests = [...registry.values()];
if (!manifests.length) { if (!manifests.length) {
@@ -1121,9 +1328,9 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
}, { sub: man.description || '' })); }, { sub: man.description || '' }));
} }
} }
}); }
wireDropdown('layout-btn', 'layout-menu', async (menu) => { wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = ''; menu.innerHTML = '';
menu.appendChild(menuItem('Save as named layout…', async () => { menu.appendChild(menuItem('Save as named layout…', async () => {
@@ -1156,19 +1363,29 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
divider.className = 'menu-divider'; divider.className = 'menu-divider';
menu.appendChild(divider); menu.appendChild(divider);
// named layouts stored on the server const reset = menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' });
menu.appendChild(reset);
// Named layouts stored on the server slot in above Reset once they
// arrive; until then (or if they never do) the menu is already usable.
let layouts = []; let layouts = [];
try { try {
const res = await fetch('/api/layouts'); const res = await fetchWithTimeout('/api/layouts', 8000);
if (res.ok) layouts = (await res.json()).layouts || []; if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ } } catch { /* server briefly away — the menu just shows none */ }
if (layouts.length) { if (!layouts.length || !stillOpen()) return;
const block = document.createDocumentFragment();
const title = document.createElement('div'); const title = document.createElement('div');
title.className = 'menu-title'; title.className = 'menu-title';
title.textContent = 'Load from server'; title.textContent = 'Load from server';
menu.appendChild(title); block.appendChild(title);
for (const l of layouts) { for (const l of layouts) {
menu.appendChild(menuItem(l.name, async () => { block.appendChild(menuItem(l.name, async () => {
menu.hidden = true; menu.hidden = true;
try { try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name)); const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
@@ -1184,15 +1401,8 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
} }
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` })); }, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
} }
menu.appendChild(divider.cloneNode()); block.appendChild(divider.cloneNode());
} menu.insertBefore(block, reset);
menu.appendChild(menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' }));
}); });
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */ /* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
@@ -1222,6 +1432,41 @@ try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false }); if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} catch { /* fine */ } } catch { /* fine */ }
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
/** How long a peeked title bar stays after the pointer leaves its tile. */
const PEEK_HIDE_MS = 10000;
const peekTimers = new Map(); // tile id → timeout
function clearPeekTimer(id) {
const timer = peekTimers.get(id);
if (timer === undefined) return;
clearTimeout(timer);
peekTimers.delete(id);
}
function schedulePeekHide(id, el) {
clearPeekTimer(id);
peekTimers.set(id, setTimeout(() => {
peekTimers.delete(id);
el.classList.remove('head-peek');
}, PEEK_HIDE_MS));
}
function peekHead(tile, el, on) {
el.classList.toggle('head-peek', on);
clearPeekTimer(tile.id);
// Opened from a click the pointer is on the tile, so the countdown waits
// for it to leave — unless it already has (a keyboard or touch open).
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
}
/** Back to a plain screen: no peeks left open, no timers left running. */
function clearPeeks() {
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
}
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ /* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally /* Fullscreen is a viewing mode. The occupied columns stretch proportionally
@@ -1355,6 +1600,7 @@ document.addEventListener('fullscreenchange', () => {
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else { } else {
setMenuHidden(menuHiddenBeforeFs, { persist: false }); setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit(); clearFullscreenFit();
} }
}); });
@@ -1381,6 +1627,7 @@ function subscribeEvents() {
try { eventsSource?.close(); } catch { /* not open */ } try { eventsSource?.close(); } catch { /* not open */ }
eventsSource = new EventSource('/api/events'); eventsSource = new EventSource('/api/events');
eventsSource.addEventListener('modules-changed', () => handleModulesChanged()); eventsSource.addEventListener('modules-changed', () => handleModulesChanged());
eventsSource.addEventListener('theme', handleThemeEvent);
eventsSource.onerror = () => { eventsSource.onerror = () => {
if (eventsSource.readyState === EventSource.CLOSED) { if (eventsSource.readyState === EventSource.CLOSED) {
clearTimeout(eventsRetry); clearTimeout(eventsRetry);
@@ -1417,6 +1664,43 @@ async function handleModulesChanged() {
} }
} }
/* ── theme ──────────────────────────────────────────────────────────── */
/* The dashboard's palette is a server-wide choice (Admin → Theme). Five
preset variable sets live in style.css as html[data-theme="…"]; applying
one is setting that attribute. The last-known theme goes on synchronously
from localStorage so a reload never flashes the default palette, then the
server's answer (GET /api/theme) wins, and a `theme` shell event on
/api/events switches every open dashboard the moment an admin picks
another. Modules style with the shell variables, so they follow; a colour
a tile is configured with is set on the tile itself and stays as chosen. */
const LS_THEME = 'proddash:theme';
function applyTheme(id) {
const theme = String(id || '');
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
}
/** `theme` shell event from /api/events (wired in subscribeEvents). */
function handleThemeEvent(e) {
try {
applyTheme(JSON.parse(e.data).theme);
} catch { /* a malformed frame changes nothing */ }
}
function initTheme() {
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
fetch('/api/theme', { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body?.theme) applyTheme(body.theme); })
.catch(() => { /* offline: the remembered theme stands */ });
}
initTheme();
/* ── boot ───────────────────────────────────────────────────────────── */ /* ── boot ───────────────────────────────────────────────────────────── */
async function boot() { async function boot() {
+174 -58
View File
@@ -10,6 +10,7 @@
--text: #e6edf3; --text: #e6edf3;
--muted: #8b98a5; --muted: #8b98a5;
--accent: #2ee59a; --accent: #2ee59a;
--accent-text: #06281a; /* text that sits on --accent (buttons, badges) */
--danger: #f4433c; --danger: #f4433c;
--warn: #f0b429; --warn: #f0b429;
/* grid geometry (shell.js reads these) */ /* grid geometry (shell.js reads these) */
@@ -54,57 +55,43 @@ body.menu-hidden #topbar {
overflow: hidden; overflow: hidden;
} }
/* The menubar's show/hide handle IS the logo: a translucent accent pill /* The bar's show/hide handle: a small accent lip on its bottom edge, the
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the same idea as a tile's notch. It sits at the left — never screen-centre,
background colour. It sits at the left — never screen-centre, where a where a tile's own (centred) tab lands once both bars are hidden — and
tile's own (centred) tab lands once both bars are hidden. When the bar grows under the pointer so it is easy to hit without being loud at rest.
hides, the pill shrinks to a slim lip at the very top edge, clear of a It used to be the ProdDash wordmark on a pill hanging past the bar, which
top-row tile's status dot and title. */ read as a step cut out of the bar, and animating its font-size on the way
.brand-spacer { visibility: hidden; } out was the jerkiest thing on the page in Safari. Everything here moves by
transform, opacity and its own fixed size: no text metrics, no reflow. */
#topbar-notch { #topbar-notch {
position: fixed; position: fixed;
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
8px past the bar's bottom edge; the logo sits centred in the bar part */
top: 0; top: 0;
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */ left: 10px;
height: calc(var(--topbar-h, 46px) + 8px); width: 28px;
min-width: 60px; height: 5px;
padding: 0 10px; padding: 0;
font: inherit;
font-weight: 800;
font-size: 15px;
letter-spacing: 0.02em;
line-height: var(--topbar-h, 46px);
color: var(--text);
background: color-mix(in srgb, var(--accent) 45%, transparent);
border: none; border: none;
border-radius: 0 0 12px 12px; border-radius: 0 0 5px 5px;
background: var(--accent);
opacity: 0.5;
cursor: pointer; cursor: pointer;
z-index: 60; z-index: 60;
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease; /* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so
the lip rides the bar's own collapse instead of running a second,
competing animation against it. */
transform: translateY(var(--topbar-h, 46px));
transition: width 0.16s ease, height 0.16s ease,
opacity 0.16s ease, border-radius 0.16s ease;
} }
#topbar-notch .accent { color: var(--bg); } /* the cutout */ #topbar-notch:hover,
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); } #topbar-notch:focus-visible {
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */ width: 60px;
body.menu-hidden #topbar-notch {
height: 10px; height: 10px;
line-height: 10px; opacity: 1;
font-size: 0; border-radius: 0 0 8px 8px;
background: color-mix(in srgb, var(--accent) 55%, transparent);
}
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
body.menu-hidden #topbar-notch::after {
content: "";
display: block;
width: 20px;
height: 3px;
margin: 0 auto;
position: relative;
top: 3px;
border-radius: 999px;
background: var(--bg);
} }
/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top
edge without a rule of its own. */
#brand { #brand {
font-weight: 800; font-weight: 800;
@@ -387,6 +374,22 @@ body.menu-hidden #topbar-notch::after {
} }
.tile-notch:hover::after { background: var(--text); } .tile-notch:hover::after { background: var(--text); }
/* Fullscreen is for watching, not arranging: every title bar folds away, and
a tile's own hidden/shown preference is left untouched for when the screen
comes back. The notch still peeks one open, and it folds back once the
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
body.fullscreen .tile:not(.head-peek) .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
body.fullscreen .tile.head-peek .tile-head {
height: 32px;
border-bottom-width: 1px;
}
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
.tile-body { .tile-body {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -433,8 +436,9 @@ body.menu-hidden #topbar-notch::after {
border-radius: 0 0 0 3px; border-radius: 0 0 0 3px;
} }
/* bottom-edge pill: drag to move the tile's bottom edge (height only); /* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
sits between the two corner handles */ the same 2px line as the corner handles it sits between, on the same
4px inset, so the three read as one set of marks along the bottom. */
.tile-resize-bottom { .tile-resize-bottom {
position: absolute; position: absolute;
left: 24px; left: 24px;
@@ -447,6 +451,7 @@ body.menu-hidden #topbar-notch::after {
justify-content: center; justify-content: center;
cursor: ns-resize; cursor: ns-resize;
touch-action: none; touch-action: none;
color: var(--muted);
opacity: 0; opacity: 0;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
} }
@@ -454,17 +459,13 @@ body.menu-hidden #topbar-notch::after {
content: ""; content: "";
width: 44px; width: 44px;
max-width: 60%; max-width: 60%;
height: 5px; height: 0;
margin-bottom: 2px; margin-bottom: 4px;
border-radius: 999px; border-bottom: 2px solid currentColor;
background: var(--muted);
transition: background 0.15s ease;
} }
.tile:hover .tile-resize-bottom { opacity: 0.6; } .tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover, .tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; } .tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
.tile .tile-resize-bottom:hover::after,
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
/* ── seam divider between adjacent tiles ───────────────────────────── */ /* ── seam divider between adjacent tiles ───────────────────────────── */
@@ -494,7 +495,8 @@ body.menu-hidden #topbar-notch::after {
transition: opacity 0.15s ease, background 0.15s ease; transition: opacity 0.15s ease, background 0.15s ease;
} }
.tile-divider:hover::after { opacity: 0.7; } .tile-divider:hover::after,
.tile-divider.linked::after { opacity: 0.7; }
.tile-divider.active::after { opacity: 1; background: var(--text); } .tile-divider.active::after { opacity: 1; background: var(--text); }
/* Hide the pills while a tile itself is being moved or corner-resized — /* Hide the pills while a tile itself is being moved or corner-resized —
@@ -588,12 +590,15 @@ body.fullscreen #grid { padding-bottom: 0; }
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); } .tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; } .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 .field:last-child { margin-bottom: 0; }
/* Settings apply as they change, so nothing sits under the last field — its
bottom margin would only pad the popover's own padding a second time. */
.tile-settings > :last-child { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns { .tile-settings .field-group-body.is-columns {
display: grid; display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px; gap: 6px 14px; /* the grid gap is the whole row spacing: no per-field margin */
} }
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; } .tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } .tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@container (max-width: 420px) { @container (max-width: 420px) {
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } .tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
@@ -635,14 +640,64 @@ body.fullscreen #grid { padding-bottom: 0; }
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
} }
/* boolean / switch settings: label on the left, switch on the right (help,
when there is any, wraps under both) */
.field.check { .field.check {
flex-direction: row; flex-direction: row;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 4px 10px;
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
} }
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); } .field.check .check-label { flex: 1 1 0; min-width: 0; }
.field.check .field-help { flex-basis: 100%; }
/* The switch: the admin page's module switch, same track, knob and colours,
so a toggle reads the same on both surfaces. On = shown/enabled (accent),
off = hidden/disabled (border track, muted knob). The real checkbox lies
invisibly over the track — still focusable, so Tab reaches it and Space
flips it — and the label around it makes the text clickable too. */
.switch {
position: relative;
display: inline-flex;
align-items: center;
flex: none;
cursor: pointer;
}
.switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
pointer-events: none;
}
.switch .track {
display: block;
position: relative;
width: 34px;
height: 19px;
border-radius: 999px;
background: var(--border);
transition: background 0.2s;
}
.switch .track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--muted);
transition: transform 0.2s, background 0.2s;
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); background: var(--bg); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
/* color fields: label on the left, swatch on the right */ /* color fields: label on the left, swatch on the right */
.field.color-field { .field.color-field {
@@ -708,7 +763,7 @@ body.fullscreen #grid { padding-bottom: 0; }
.btn.primary { .btn.primary {
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
color: #06281a; color: var(--accent-text);
font-weight: 600; font-weight: 600;
} }
@@ -755,3 +810,64 @@ body.fullscreen #grid { padding-bottom: 0; }
@media (max-width: 560px) { @media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; } #topbar { padding: 0 8px; gap: 6px; }
} }
/* ── themes ────────────────────────────────────────────────────────── */
/* Preset palettes, chosen in Admin → Theme and applied by shell.js (and the
admin page) as html[data-theme="…"]. Each swaps exactly the eight palette
variables the module guide names — nothing else — so every module and
every shell element follows, and a colour a tile is configured with (set
on the tile itself) still wins. The :root block at the top of this file is
"booth", the default; the list of ids and names lives in server.js
(GET /api/theme). All four are booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable. */
/* Harbor — deep navy, cyan accent */
html[data-theme="harbor"] {
--bg: #06101f;
--panel: #0c1a30;
--border: #1a2b48;
--text: #e6eefb;
--muted: #8ea4c8;
--accent: #2ad4ee;
--danger: #ff5a5f;
--warn: #f7b53b;
}
/* Graphite — neutral charcoal, electric-blue accent */
html[data-theme="graphite"] {
--bg: #0f1012;
--panel: #17191d;
--border: #2a2d33;
--text: #f0f2f4;
--muted: #9ca3ad;
--accent: #6cb4ff;
--danger: #ff4d4f;
--warn: #ffb224;
}
/* Ember — warm broadcast black, orange accent (danger stays a cool red,
warn a pale yellow, so the three status colours never blur) */
html[data-theme="ember"] {
--bg: #120c0a;
--panel: #1d1411;
--border: #35251e;
--text: #f6ede6;
--muted: #b39b8c;
--accent: #ff8f3a;
--danger: #f03247;
--warn: #ffd86b;
}
/* Daylight — the light one, for a front-of-house desk in daytime */
html[data-theme="daylight"] {
--bg: #eef1f5;
--panel: #ffffff;
--border: #cfd6e0;
--text: #14181f;
--muted: #5b6675;
--accent: #0b8a5f;
--accent-text: #ffffff; /* dark text drowns on this accent */
--danger: #d1242f;
--warn: #b26a00;
}
+83 -3
View File
@@ -373,14 +373,17 @@ function mountModule(id) {
// Re-require fresh config on every (re)mount; the module code itself stays cached. // Re-require fresh config on every (re)mount; the module code itself stays cached.
const mod = require(serverPath); const mod = require(serverPath);
const config = effectiveConfig(id); const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's
// routes over loopback, chiefly — without guessing.
const shell = { port: PORT };
if (typeof mod.init === 'function') { if (typeof mod.init === 'function') {
entry.handle = mod.init({ config, log }) || null; entry.handle = mod.init({ config, log, shell }) || null;
} }
if (typeof mod.routes === 'function') { if (typeof mod.routes === 'function') {
entry.routes = parseRouteTable(mod.routes({ config, log })); entry.routes = parseRouteTable(mod.routes({ config, log, shell }));
} }
if (typeof mod.tiles === 'function') { if (typeof mod.tiles === 'function') {
entry.tiles = () => mod.tiles({ config, log }); entry.tiles = () => mod.tiles({ config, log, shell });
} }
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
} catch (err) { } catch (err) {
@@ -510,6 +513,8 @@ async function clientManifest(id) {
instanceSchema: man.instanceSchema || {}, instanceSchema: man.instanceSchema || {},
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
hasServer: Boolean(man.server), hasServer: Boolean(man.server),
// capabilities other modules consume ("timers") — see the module guide
provides: Array.isArray(man.provides) ? man.provides.map(String) : [],
config: clientConfig(id), config: clientConfig(id),
tiles: await moduleTiles(id), tiles: await moduleTiles(id),
}; };
@@ -541,6 +546,60 @@ setInterval(() => {
} }
}, 25000).unref(); }, 25000).unref();
/* ── themes ─────────────────────────────────────────────────────────── */
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
variable sets themselves live in public/style.css (html[data-theme="…"]);
this list is the one authority on ids and names, and carries the three
colours the admin page previews a theme with. The choice is a shell
setting — `theme` in the data directory's proddash.json — and reaches open
dashboards live as a `theme` shell event. */
const THEMES = [
{ id: 'booth', name: 'Booth', colors: { bg: '#0b0e12', panel: '#131920', accent: '#2ee59a' } },
{ id: 'harbor', name: 'Harbor', colors: { bg: '#06101f', panel: '#0c1a30', accent: '#2ad4ee' } },
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
];
const DEFAULT_THEME = THEMES[0].id;
function themeExists(id) {
return THEMES.some((t) => t.id === id);
}
let currentTheme = themeExists(shellConfig.theme) ? shellConfig.theme : DEFAULT_THEME;
/** What GET /api/theme returns — public, since dashboards need it before anyone logs in. */
function themeView() {
return {
theme: currentTheme,
themes: THEMES.map((t) => ({ id: t.id, name: t.name, colors: { ...t.colors } })),
};
}
/**
* Persist the theme into this machine's proddash.json (every other key there
* — port, adminPasscode, repo… — is kept), remember it, and tell every open
* dashboard and admin page. Throws an httpError for an unknown id or an
* unreadable settings file; the route turns that into the response.
*/
function setTheme(id) {
if (!themeExists(id)) throw httpError(400, `Unknown theme "${id}".`);
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the theme.`);
}
}
writeJson(file, { ...existing, theme: id });
currentTheme = id;
shellConfig.theme = id;
broadcastShellEvent('theme', { theme: id });
return themeView();
}
/* ── the repo: module catalog and shell updates ──────────────────────── */ /* ── the repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the /* The admin page installs modules from, and updates the shell against, the
@@ -1109,6 +1168,22 @@ async function handleAdminApi(req, res, urlPath) {
return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
} }
/* — theme: { "theme": "<id>" }, validated against THEMES — */
if (urlPath === '/api/admin/theme' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, ...setTheme(String(body.theme ?? '')) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
sendJson(res, 404, { error: 'Unknown admin endpoint.' }); sendJson(res, 404, { error: 'Unknown admin endpoint.' });
} }
@@ -1352,6 +1427,11 @@ function handleRequest(req, res) {
return; return;
} }
/* — theme: public (dashboards need it without a passcode); changed via POST /api/admin/theme — */
if (urlPath === '/api/theme' && req.method === 'GET') {
return sendJson(res, 200, themeView());
}
/* — shell events: open dashboards learn about admin changes live — */ /* — shell events: open dashboards learn about admin changes live — */
if (urlPath === '/api/events' && req.method === 'GET') { if (urlPath === '/api/events' && req.method === 'GET') {
res.writeHead(200, { res.writeHead(200, {