Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a6def839 |
@@ -13,6 +13,3 @@ launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
launcher/windows/build/
|
||||
launcher/windows/.cache/
|
||||
|
||||
# the Viewer's build output
|
||||
viewer/macos/build/
|
||||
|
||||
@@ -4,13 +4,10 @@ Modular production dashboard web app for Waters Church, developed in this repo.
|
||||
The app lives at the repo root and is built: zero-dependency Node server
|
||||
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
||||
none and installs them from the admin page's catalog), a native macOS menu-bar launcher in
|
||||
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
|
||||
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||
the server and its own Node runtime, supervises them, and owns the macOS
|
||||
permissions modules declare; see its README), and **ProdDash Viewer** in
|
||||
`viewer/macos/` (Swift, no dependencies — a separate Apple-silicon app that
|
||||
only displays one or more ProdDash installations in WebKit, with an
|
||||
auto-hiding tab bar; it runs no server; see its README). Runtime state (module
|
||||
permissions modules declare; see its README). Runtime state (module
|
||||
config, named layouts) is written to a per-machine data directory outside the
|
||||
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
||||
kept") — never add code that writes runtime state into the repo folder.
|
||||
@@ -55,7 +52,6 @@ title-bar controls). The rules that matter most:
|
||||
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`).
|
||||
|
||||
## Reference code (read-only forks — don't develop these here)
|
||||
|
||||
|
||||
@@ -316,15 +316,10 @@ below. Nothing is installed without someone clicking.
|
||||
name, load a named layout (it becomes a local copy — browsers are never
|
||||
linked), or reset this browser's dashboard. Loading says what it did —
|
||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||
now; the Layouts menu can remove them* — with **Undo** in the message to
|
||||
get back the arrangement you had. A tile whose module or picker entry
|
||||
isn't offered at the moment keeps its place and shows nothing, then
|
||||
appears when it is; it is never replaced by the module's default tile.
|
||||
While any tile is in that state the same menu has a **Not shown** section
|
||||
listing each one with why — *not offered right now*, *module disabled*,
|
||||
*module not installed* — and a ✕ that removes it and frees its cells, for
|
||||
the tile that is not coming back (a module that stopped offering that
|
||||
kind of tile). Saving over an existing name asks first, and
|
||||
now* — with **Undo** in the message to get back the arrangement you had.
|
||||
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||
place and shows nothing, then appears when it is; it is never replaced by
|
||||
the module's default tile. Saving over an existing name asks first, and
|
||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||
@@ -521,28 +516,11 @@ below. Nothing is installed without someone clicking.
|
||||
A photo library fills it: point the module at a Google Drive folder shared
|
||||
"anyone with the link" (file name → person's name) or at a folder on the
|
||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||
title bar opens that library — the spots pinned on top, the photos (with a
|
||||
name search) scrolling below — drag a photo onto a slot (mouse, or press and
|
||||
hold on a touchscreen), drag between slots to swap, rename or clear.
|
||||
Dropping a photo onto the person already in a spot makes it *their* photo
|
||||
for that instrument (a nickname like "Rose" for Rosaliz counts): they keep
|
||||
their Planning Center name and spot, and every later Sync uses it whenever
|
||||
they are on that instrument.
|
||||
Planning Center is optional auto-fill on top, not the source of truth:
|
||||
**Sync** reads the upcoming plan's team members and places each one. A
|
||||
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
|
||||
that slot — this is how the leader and vocalists, whose position is just
|
||||
"Vocalist", land on the right mic — and everyone else goes to the slot
|
||||
matching their position, one slot per person. Photos come from the library
|
||||
whenever it has one of them, and Planning Center's headshot only when it
|
||||
has none. Someone who plays more than one thing can have a photo per
|
||||
instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`;
|
||||
Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and
|
||||
a plain `DJ.jpg` for everything else: Sync uses the one for the spot they
|
||||
are in that week. A photo dropped onto someone is remembered for that
|
||||
instrument only. Anything placed or moved by
|
||||
hand for that plan stays put through later syncs; last week's people are
|
||||
replaced.
|
||||
title bar opens that library — drag a photo onto a slot, drag between slots
|
||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
||||
drops each into the slot matching their position, filling only slots nobody
|
||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||
@@ -567,36 +545,6 @@ below. Nothing is installed without someone clicking.
|
||||
soon as it does) and **Marks** (how long a mark lives).
|
||||
Tile settings: campuses, card or list layout, and a **Show** group for the
|
||||
day, role, resi source, campuses fed, notes and the updated line.
|
||||
- **Vista Cues** — which Vista 3 cue lists are live, for a booth where
|
||||
nobody runs lighting and ProPresenter and the tracks rig fire Vista's cue
|
||||
lists over network MIDI. Vista sends no feedback of any kind (its OSC only
|
||||
takes commands), so the module listens instead: it joins the senders'
|
||||
network MIDI sessions as one more participant, the way Vista does, hears
|
||||
every note Vista hears, and turns each one into "Go on Walk-in", "Release
|
||||
Worship 1"… from the cue sheet pasted into admin. The tile shows the list
|
||||
fired last, large, with how long ago and who fired it; the other lists
|
||||
still live; the recent notes; and which senders are connected. What it
|
||||
shows is what Vista was *told*: if Vista is closed or someone works the
|
||||
desk by hand, the two can differ, and the title bar's eraser forgets every
|
||||
live list. Plain JavaScript (RTP-MIDI over UDP), no helper; on macOS it
|
||||
advertises itself over Bonjour so it shows up in Audio MIDI Setup's
|
||||
Directory. Admin config: up to three senders (a name, and the host and
|
||||
port of each machine's network session — ProPresenter, the tracks rig,
|
||||
Companion; ProdDash keeps inviting itself in, so each session's "Who may
|
||||
connect to me" must be Anyone, or connect from the sender's Directory
|
||||
instead), the cue sheet (one line per note: `0 = Pre Service`,
|
||||
`61 release Walk-in`, `127 release all`, `30 flash Blinder Flash 1` for a
|
||||
momentary hit, also pause, back, note names, a channel, program changes),
|
||||
middle C naming, whether a Go releases the others, and a folded **This
|
||||
machine** group for the session name, port (UDP 5040 and 5041) and
|
||||
Bonjour. A sheet with no release notes uses groups: a line `[Color]`
|
||||
starts one, a Go in a group replaces that group's live list, and the
|
||||
first group is the tile's headline — so the tile reads "Message" with
|
||||
Color · Blue and House · House Lights Message beside it. A Go in that
|
||||
first group (the main scene) takes over the look and clears the rest, as
|
||||
Vista's last-takes-precedence does; FX fired after it layer on again
|
||||
(the **Main group takes over** switch). Tile settings: other lists,
|
||||
recent, senders, unmapped notes — text size is − / + in the title bar.
|
||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||
reference for module authors.
|
||||
|
||||
@@ -612,25 +560,9 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
|
||||
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
|
||||
board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI session playing a cue
|
||||
sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## ProdDash Viewer — a Mac that only shows the dashboard
|
||||
|
||||
For a Mac that should show ProdDash and nothing else — a booth display, a
|
||||
second screen watching the other campus — there is **ProdDash Viewer**
|
||||
([viewer/macos](viewer/macos)): one window, the dashboard, and a slim
|
||||
auto-hiding tab bar when it is set up with more than one installation. It
|
||||
runs no server of its own; point it at any ProdDash on the network. Full
|
||||
screen, reopening as it was left, and keeping the display awake are built in.
|
||||
|
||||
```bash
|
||||
cd viewer/macos && ./build.sh --dmg
|
||||
```
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
Open the page in a browser first, then:
|
||||
@@ -661,9 +593,6 @@ ProdDash/
|
||||
├── package.json name + version (what the update check compares)
|
||||
├── config/ proddash.json defaults (runtime state lives in the
|
||||
│ per-machine data directory — see "Configure")
|
||||
├── launcher/macos/ the macOS app: menu-bar launcher carrying the server
|
||||
│ and its own Node (launcher/windows: the Windows bundle)
|
||||
├── viewer/macos/ ProdDash Viewer: a Mac app that only shows dashboards
|
||||
├── docs/MODULE-GUIDE.md how to build and install a module
|
||||
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
|
||||
├── tools/prodcom-mock.js mock ProdCom API for development
|
||||
|
||||
@@ -688,11 +688,8 @@ module.exports = {
|
||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||
in your list right now; otherwise the tile keeps its place in the layout,
|
||||
draws nothing, and appears the moment your list offers it again — a
|
||||
Service timer waits for PCO Plan, a transcript for its channel. The
|
||||
operator can remove such a tile from the Layouts menu's **Not shown**
|
||||
section, so an entry you retire for good (a send box that moved to another
|
||||
module) leaves no dead cells behind. So your client is never asked to
|
||||
render an entry you did not offer at mount time.
|
||||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||
client is never asked to render an entry you did not offer at mount time.
|
||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||
placeholder, no error — and the shell folds the tile away at its next
|
||||
@@ -1047,8 +1044,7 @@ client-side failures (a tile that fails to start says so in the tile).
|
||||
|
||||
**Poke the pieces directly:**
|
||||
|
||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret
|
||||
config; `inactive` names the installed modules that are not running).
|
||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
|
||||
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
||||
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
||||
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
||||
|
||||
+54
-142
@@ -1,5 +1,5 @@
|
||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||
// (Wed→Tue) rains confetti.
|
||||
@@ -14,108 +14,26 @@ export default function create({ root, moduleApi }) {
|
||||
let selectedLibId = ''; // click-to-place fallback for touch
|
||||
const canvases = new Set();
|
||||
let raf = null;
|
||||
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
|
||||
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
|
||||
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
|
||||
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
|
||||
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
|
||||
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
|
||||
* edges. Board updates that arrive during a drag wait until it ends. */
|
||||
const DRAG_MOVE_PX = 6;
|
||||
const TOUCH_HOLD_MS = 220;
|
||||
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
|
||||
let pending = null; // a pointerdown that may become a drag
|
||||
let renderHeld = false; // a state update arrived mid-drag; render when it ends
|
||||
|
||||
function slotUnder(x, y) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
|
||||
return cell && root.contains(cell) ? cell : null;
|
||||
}
|
||||
function beginDrag(p) {
|
||||
const ghost = el('div', 'bl-drag-ghost');
|
||||
ghost.dataset.module = 'band-lineup'; // lives on <body> (above every tile), so it carries its own scope
|
||||
const src = p.source.querySelector('img');
|
||||
if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); }
|
||||
else ghost.textContent = p.label || '';
|
||||
document.body.appendChild(ghost);
|
||||
drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source };
|
||||
p.source.classList.add('bl-dragging');
|
||||
root.classList.add('bl-is-dragging');
|
||||
moveDrag(p.x, p.y);
|
||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||
let dragScrolling = false;
|
||||
let dragY = 0;
|
||||
let dragBody = null;
|
||||
let dragRaf = null;
|
||||
function startDragAutoScroll() {
|
||||
if (dragRaf) return;
|
||||
const tick = () => {
|
||||
if (!drag) return;
|
||||
// Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
|
||||
const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
|
||||
if (b && b.scrollHeight > b.clientHeight) {
|
||||
const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
|
||||
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
|
||||
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
|
||||
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
|
||||
}
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||
const r = dragBody.getBoundingClientRect();
|
||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||
let dy = 0;
|
||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||
if (dy) dragBody.scrollTop += dy;
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
function moveDrag(x, y) {
|
||||
drag.x = x; drag.y = y;
|
||||
drag.ghost.style.transform = `translate(${x}px, ${y}px)`;
|
||||
const cell = slotUnder(x, y);
|
||||
if (cell !== drag.over) {
|
||||
if (drag.over) drag.over.classList.remove('bl-drop-over');
|
||||
drag.over = cell;
|
||||
if (cell) cell.classList.add('bl-drop-over');
|
||||
}
|
||||
}
|
||||
function endDrag(drop) {
|
||||
const d = drag; drag = null;
|
||||
if (!d) return;
|
||||
if (d.raf) cancelAnimationFrame(d.raf);
|
||||
d.ghost.remove();
|
||||
d.source.classList.remove('bl-dragging');
|
||||
root.classList.remove('bl-is-dragging');
|
||||
if (d.over) d.over.classList.remove('bl-drop-over');
|
||||
if (drop && d.over) {
|
||||
const to = d.over.dataset.index;
|
||||
if (d.kind === 'lib') assign(to, d.id);
|
||||
else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' });
|
||||
}
|
||||
if (renderHeld) { renderHeld = false; render(); }
|
||||
}
|
||||
function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; }
|
||||
// Make `node` draggable. `kind`/`id` say what is being dragged.
|
||||
function makeDraggable(node, kind, id, body, label) {
|
||||
node.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || drag) return;
|
||||
if (e.target.closest('button')) return; // name / clear / delete buttons keep working
|
||||
cancelPending();
|
||||
pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null };
|
||||
if (pending.touch) {
|
||||
const p = pending;
|
||||
p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Document-level move/up so the drag survives leaving the card it started on.
|
||||
function onPointerMove(e) {
|
||||
if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; }
|
||||
if (!pending || e.pointerId !== pending.pointerId) return;
|
||||
const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy);
|
||||
if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll
|
||||
if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); }
|
||||
}
|
||||
function onPointerUp(e) {
|
||||
if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); }
|
||||
cancelPending();
|
||||
}
|
||||
let suppressClick = false; // the click that follows a drop must not also select / place
|
||||
// While a finger drag is live, stop the page from scrolling under it.
|
||||
function onTouchMove(e) { if (drag) e.preventDefault(); }
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.addEventListener('pointercancel', onPointerUp);
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
function stopDragAutoScroll() { cancelPending(); endDrag(false); }
|
||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||
|
||||
/* ---------- confetti (always animates) ---------- */
|
||||
function makeParticle(w, h, intensity, seedY) {
|
||||
@@ -161,12 +79,11 @@ export default function create({ root, moduleApi }) {
|
||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||
|
||||
function render() {
|
||||
if (drag) { renderHeld = true; return; }
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||
if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
|
||||
root.innerHTML = '';
|
||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||
if (editing) { renderEditor(); return; }
|
||||
|
||||
const board = el('div', 'bl-board');
|
||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||
@@ -241,16 +158,13 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
let libFilter = ''; // the photo search box, kept across re-renders
|
||||
function renderEditor() {
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
|
||||
// that scroll on their own: the spots pinned on top, the photos below — so a photo found far
|
||||
// down the list never has to be carried back up to its spot. Both panes keep their scroll
|
||||
// position (and the search box its focus) when a board update redraws the editor.
|
||||
const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
|
||||
const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
||||
// board on top and the picture picker (library) below it.
|
||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||
const body = el('div', 'bl-editor-body bl-editor-split');
|
||||
const body = el('div', 'bl-editor-body');
|
||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||
|
||||
// Slots (drop targets)
|
||||
const boardEl = el('div', 'bl-edit-board');
|
||||
@@ -262,7 +176,14 @@ export default function create({ root, moduleApi }) {
|
||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||
cell.appendChild(photo);
|
||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
|
||||
if (slot.person) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||
}
|
||||
const nameRow = el('div', 'bl-edit-namerow');
|
||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||
@@ -270,23 +191,26 @@ export default function create({ root, moduleApi }) {
|
||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||
cell.appendChild(nameRow);
|
||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
// drop
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||
const data = e.dataTransfer.getData('text/plain') || '';
|
||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||
});
|
||||
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
const boardPane = el('div', 'bl-pane bl-board-pane');
|
||||
boardPane.appendChild(boardEl);
|
||||
body.appendChild(boardPane);
|
||||
body.appendChild(boardEl);
|
||||
|
||||
// Library, with a name search
|
||||
const libPane = el('div', 'bl-pane bl-lib-pane');
|
||||
const search = el('input', 'bl-lib-search');
|
||||
search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
|
||||
libPane.appendChild(search);
|
||||
// Library
|
||||
const lib = el('div', 'bl-library');
|
||||
const list = state?.library || [];
|
||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||
for (const person of list) {
|
||||
const item = el('div', 'bl-lib-item'); item.dataset.id = person.id;
|
||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||
@@ -295,27 +219,19 @@ export default function create({ root, moduleApi }) {
|
||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||
item.appendChild(del);
|
||||
makeDraggable(item, 'lib', person.id, body, person.name);
|
||||
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
item.addEventListener('dragend', stopDragAutoScroll);
|
||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
lib.appendChild(item);
|
||||
}
|
||||
const applyFilter = () => {
|
||||
const q = libFilter.trim().toLowerCase();
|
||||
for (const item of lib.querySelectorAll('.bl-lib-item')) {
|
||||
const nm = item.querySelector('.bl-lib-name');
|
||||
item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
|
||||
}
|
||||
};
|
||||
search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
|
||||
applyFilter();
|
||||
libPane.appendChild(lib);
|
||||
body.appendChild(libPane);
|
||||
body.appendChild(lib);
|
||||
wrap.appendChild(body);
|
||||
|
||||
root.innerHTML = '';
|
||||
root.appendChild(wrap);
|
||||
boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
|
||||
if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
|
||||
}
|
||||
|
||||
function assign(slotIndex, personId) {
|
||||
@@ -344,7 +260,7 @@ export default function create({ root, moduleApi }) {
|
||||
title: 'Sync',
|
||||
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
||||
});
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
|
||||
// Columns −/+ (touch-friendly; replaces the gear number field).
|
||||
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
||||
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
@@ -377,10 +293,6 @@ export default function create({ root, moduleApi }) {
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
stopDragAutoScroll();
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
|
||||
+11
-133
@@ -117,61 +117,9 @@ function scoreMatch(slotLabel, positionLabel) {
|
||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||
*/
|
||||
const MIN_SCORE = 60;
|
||||
|
||||
/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */
|
||||
function mixNumbers(value) {
|
||||
const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m;
|
||||
while ((m = re.exec(String(value || '')))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a person's Planning Center note names a spot. The booth writes the mic/role into each
|
||||
* vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those
|
||||
* change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a
|
||||
* different mix than the spot is never a match; the role part ("VOX 5") is scored like a position.
|
||||
* 0 means the note says nothing usable about this spot ("NA", blank, or no overlap).
|
||||
*/
|
||||
function noteScore(slotLabel, note) {
|
||||
const text = String(note || '').trim();
|
||||
if (!text || /^n\/?a$/i.test(text)) return 0;
|
||||
const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text);
|
||||
let mixBonus = 0;
|
||||
if (noteMix.size && slotMix.size) {
|
||||
if (![...noteMix].some((n) => slotMix.has(n))) return 0;
|
||||
mixBonus = 100;
|
||||
}
|
||||
const role = text.replace(/\bmix\s*\d+\b/gi, ' ');
|
||||
const roleScore = scoreMatch(slotRoleText(slotLabel), role);
|
||||
if (!roleScore && !mixBonus) return 0;
|
||||
if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) {
|
||||
// The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither.
|
||||
const roleTokens = labelTokens(role);
|
||||
if (roleTokens.length) return 0;
|
||||
}
|
||||
return mixBonus + roleScore;
|
||||
}
|
||||
|
||||
function assignMembersToSlots(slots, members) {
|
||||
const pool = members.slice();
|
||||
const result = slots.map(() => null);
|
||||
const take = (chosen) => {
|
||||
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
|
||||
const key = chosen.personId || '';
|
||||
for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1);
|
||||
};
|
||||
// Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first.
|
||||
const pairs = [];
|
||||
slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); }));
|
||||
pairs.sort((a, b) => b.sc - a.sc);
|
||||
for (const { s, m } of pairs) {
|
||||
if (result[s] || !pool.includes(m)) continue;
|
||||
result[s] = m; take(m);
|
||||
}
|
||||
// Pass 2 — everyone else by Planning Center position, into the spots still open.
|
||||
return result.map((placed, s) => {
|
||||
if (placed) return placed;
|
||||
const slotLabel = slots[s];
|
||||
return slots.map((slotLabel) => {
|
||||
const role = slotRoleText(slotLabel);
|
||||
let bestIndex = -1;
|
||||
let bestScore = MIN_SCORE - 1;
|
||||
@@ -180,66 +128,18 @@ function assignMembersToSlots(slots, members) {
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
const chosen = pool[bestIndex];
|
||||
take(chosen);
|
||||
return chosen;
|
||||
return pool.splice(bestIndex, 1)[0];
|
||||
});
|
||||
}
|
||||
|
||||
/* ── photos by instrument ──────────────────────────────────────────────
|
||||
* A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg",
|
||||
* "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo
|
||||
* whose instrument matches the spot the person is in that week. Instruments group into families so
|
||||
* the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass,
|
||||
* Guitar/Electric/Acoustic/Rhythm, Leader. */
|
||||
const ROLE_FAMILY = {
|
||||
vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys',
|
||||
drums: 'drums', percussion: 'drums', bass: 'bass',
|
||||
guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader',
|
||||
};
|
||||
/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */
|
||||
function roleFamilies(text) {
|
||||
const out = new Set();
|
||||
for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); }
|
||||
return out;
|
||||
}
|
||||
/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */
|
||||
function spotFamilies(slotLabel, positionLabel) {
|
||||
const fromSlot = roleFamilies(slotRoleText(slotLabel));
|
||||
return fromSlot.size ? fromSlot : roleFamilies(positionLabel || '');
|
||||
}
|
||||
/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}.
|
||||
* The first word is always the person; instrument words after it are read off, other words
|
||||
* ("action", "2") are just part of the photo's description. */
|
||||
function photoParts(libName) {
|
||||
const words = normalizeLabel(libName).split(' ').filter(Boolean);
|
||||
const roles = new Set(); const person = [];
|
||||
words.forEach((w, i) => {
|
||||
const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null;
|
||||
if (f) roles.add(f); else if (!roles.size) person.push(w);
|
||||
});
|
||||
return { person: person.join(' '), roles };
|
||||
}
|
||||
/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick
|
||||
* never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under
|
||||
* its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */
|
||||
const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar'];
|
||||
function photoKey(personId, families) {
|
||||
const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || '';
|
||||
return f ? `${personId}|${f}` : String(personId || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||||
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||||
* preferring the most specific. Returns the entry, or null when no library photo is a confident
|
||||
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
|
||||
* not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
|
||||
* `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the
|
||||
* person is on this week: a photo for that instrument beats their everyday photo, which beats a
|
||||
* photo of them on something else — but any photo of theirs still beats PCO's headshot.
|
||||
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
|
||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||||
* `entries` is [{ ...whatever, name }].
|
||||
*/
|
||||
function bestLibraryMatch(pcoName, entries, want) {
|
||||
function bestLibraryMatch(pcoName, entries) {
|
||||
const target = normalizeLabel(pcoName);
|
||||
if (!target) return null;
|
||||
const T = target.split(' ').filter(Boolean);
|
||||
@@ -271,39 +171,19 @@ function bestLibraryMatch(pcoName, entries, want) {
|
||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||
return 0;
|
||||
};
|
||||
const wanted = want && want.size ? want : null;
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
let tie = false;
|
||||
for (const e of entries) {
|
||||
const parts = photoParts(e.name);
|
||||
let s = scoreOf(parts.roles.size ? parts.person : e.name);
|
||||
if (s < 60) continue; // not them
|
||||
if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20;
|
||||
if (s > bestScore) { bestScore = s; best = e; }
|
||||
const s = scoreOf(e.name);
|
||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||||
else if (s === bestScore && s > 0) tie = true;
|
||||
}
|
||||
if (!best) return null; // no confident match → PCO photo
|
||||
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could this library photo be of this Planning Center person? Used when someone drops a photo onto
|
||||
* a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
|
||||
* A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
|
||||
* first three letters (a nickname: "Rose" → "Rosaliz").
|
||||
*/
|
||||
function samePerson(pcoName, libName) {
|
||||
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
|
||||
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || '';
|
||||
if (a && a === b) return true;
|
||||
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
samePerson,
|
||||
roleFamilies,
|
||||
spotFamilies,
|
||||
photoParts,
|
||||
photoKey,
|
||||
normalizeTeamName,
|
||||
isAllowedTeamName,
|
||||
bestLibraryMatch,
|
||||
@@ -314,7 +194,5 @@ module.exports = {
|
||||
numbersIn,
|
||||
scoreMatch,
|
||||
assignMembersToSlots,
|
||||
noteScore,
|
||||
mixNumbers,
|
||||
MIN_SCORE,
|
||||
};
|
||||
|
||||
@@ -61,83 +61,7 @@ console.log('assignment:', JSON.stringify(out));
|
||||
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||
// exact full name beats a first-name photo
|
||||
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
||||
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
|
||||
assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
|
||||
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
|
||||
// tie → no match (two identical-scoring photos)
|
||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
|
||||
{
|
||||
const P = (label, name, personId) => ({ label, name, personId });
|
||||
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
|
||||
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
||||
console.log('one spot per person: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
|
||||
{
|
||||
const { noteScore } = require('./matching');
|
||||
const NA_SLOTS = [
|
||||
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
|
||||
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
|
||||
];
|
||||
const P = (label, name, personId, note) => ({ label, name, personId, note });
|
||||
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
|
||||
const oct25 = [
|
||||
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
|
||||
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
|
||||
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
|
||||
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
|
||||
];
|
||||
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
|
||||
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
|
||||
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
|
||||
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
|
||||
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
|
||||
// A note never pulls someone onto a different mix.
|
||||
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
|
||||
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
|
||||
console.log('notes: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- samePerson: is a dropped photo of the person already in the spot? ---
|
||||
{
|
||||
const { samePerson } = require('./matching');
|
||||
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
|
||||
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
|
||||
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
|
||||
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
|
||||
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
|
||||
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
|
||||
console.log('samePerson: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
|
||||
{
|
||||
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
|
||||
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
|
||||
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
|
||||
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
|
||||
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
|
||||
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
|
||||
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
|
||||
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
|
||||
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
|
||||
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
|
||||
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
|
||||
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
|
||||
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
|
||||
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
|
||||
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
|
||||
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
|
||||
assert.strictEqual(photoKey('dj', new Set()), 'dj');
|
||||
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
|
||||
console.log('photos by instrument: all assertions passed ✓');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "band-lineup",
|
||||
"name": "Band Lineup",
|
||||
"version": "1.13.0",
|
||||
"version": "1.11.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||
"client": "client.js",
|
||||
|
||||
@@ -32,7 +32,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
@@ -219,27 +219,6 @@ module.exports = {
|
||||
const settings = readJsonFile(settingsPath, {}) || {};
|
||||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
||||
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||||
// The photo the booth picked for a Planning Center person on an instrument ("<PCO person id>|drums"
|
||||
// → library id; a bare person id is their everyday pick), learned when a library photo is dropped
|
||||
// onto that person. Sync uses it before any name matching.
|
||||
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
|
||||
function rememberPhoto(key, libId) {
|
||||
if (!key || !libId || settings.photoFor[key] === libId) return;
|
||||
settings.photoFor[key] = libId; saveSettings();
|
||||
}
|
||||
// Library photo for a scheduled person in a spot whose instruments are `fams`:
|
||||
// 1. the photo picked for them on this instrument;
|
||||
// 2. a photo named for this instrument ("DJ - Drums");
|
||||
// 3. their everyday pick, else their best-named photo (an other-instrument photo of them
|
||||
// still beats PCO's headshot).
|
||||
function libraryPhotoFor(personId, name, fams) {
|
||||
const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null);
|
||||
const forRole = live(settings.photoFor[photoKey(personId, fams)]);
|
||||
if (forRole) return forRole;
|
||||
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams);
|
||||
if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m;
|
||||
return live(settings.photoFor[personId]) || (m && m.file ? m : null);
|
||||
}
|
||||
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||
|
||||
const CHOIR_LABEL = 'CHOIR';
|
||||
@@ -268,7 +247,7 @@ module.exports = {
|
||||
return {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
|
||||
plan: null,
|
||||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||
library: [], choir: choirOn,
|
||||
};
|
||||
@@ -334,7 +313,7 @@ module.exports = {
|
||||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
||||
*/
|
||||
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums"
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||||
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||
function scanFolder() {
|
||||
if (!photoFolder) return false;
|
||||
@@ -470,13 +449,11 @@ module.exports = {
|
||||
if (!e) return null;
|
||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||
if (e.pco) {
|
||||
// A synced person. Their library photo (if any) is read live, so replacing that photo
|
||||
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
|
||||
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
|
||||
manual = false; personId = e.pco.personId || '';
|
||||
name = String(e.pco.name || '').trim();
|
||||
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
||||
file = (lib && lib.file) || e.pco.file || '';
|
||||
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
||||
file = e.pco.file || '';
|
||||
birthday = e.pco.birthday || '';
|
||||
} else if (e.personId) {
|
||||
const lib = library[e.personId];
|
||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||
@@ -516,8 +493,7 @@ module.exports = {
|
||||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||
if (pco.configured && serviceTypeId) {
|
||||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
|
||||
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
|
||||
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||
else {
|
||||
|
||||
@@ -535,7 +511,6 @@ module.exports = {
|
||||
personId,
|
||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||
label: String(m.attributes?.team_position_name || '').trim(),
|
||||
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||
});
|
||||
if (personId) seen.add(personId);
|
||||
@@ -544,29 +519,15 @@ module.exports = {
|
||||
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||||
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||||
|
||||
// A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
|
||||
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
|
||||
// Planning Center name, kept in that spot.
|
||||
for (const [i, e] of Object.entries(board)) {
|
||||
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
|
||||
const libName = library[e.personId].name;
|
||||
const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
|
||||
if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
|
||||
const m = who[0];
|
||||
rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId);
|
||||
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan };
|
||||
}
|
||||
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
||||
|
||||
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
|
||||
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
|
||||
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
|
||||
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||
const eff = effectiveSlots();
|
||||
const planId = String(plan.id);
|
||||
const openIdx = []; const manualNames = new Set();
|
||||
for (let i = 0; i < eff.length; i += 1) {
|
||||
const e = board[String(i)];
|
||||
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||||
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (e && (e.personId || e.name)) {
|
||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (nm) manualNames.add(normName(nm));
|
||||
} else openIdx.push(i);
|
||||
}
|
||||
@@ -576,12 +537,11 @@ module.exports = {
|
||||
for (let k = 0; k < openIdx.length; k += 1) {
|
||||
const i = openIdx[k]; const m = assigned[k];
|
||||
if (m) {
|
||||
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
|
||||
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
|
||||
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
|
||||
} else if (board[String(i)]) {
|
||||
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||
} else if (board[String(i)] && board[String(i)].pco) {
|
||||
delete board[String(i)]; // was synced, no longer on the plan
|
||||
}
|
||||
}
|
||||
saveBoard();
|
||||
@@ -620,9 +580,6 @@ module.exports = {
|
||||
current = {
|
||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||
planId: () => (latest.plan && latest.plan.id) || '',
|
||||
rememberPhoto,
|
||||
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
|
||||
photoUrlFor, libraryList,
|
||||
setChoir, choirOn: () => choirOn,
|
||||
};
|
||||
@@ -713,10 +670,6 @@ module.exports = {
|
||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||
const a = current.board[from]; const b = current.board[to];
|
||||
// Moving a spot is a hand placement for this plan: Sync must not move it back.
|
||||
const plan = current.planId();
|
||||
if (a) a.plan = plan;
|
||||
if (b) b.plan = plan;
|
||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
@@ -731,14 +684,7 @@ module.exports = {
|
||||
const personId = String(q.get('personId') || '').trim();
|
||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||
const here = current.board[slot];
|
||||
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
|
||||
// A photo of the person already in this spot: it's their photo now — this week and every
|
||||
// Sync after. They keep their Planning Center name and their spot.
|
||||
// Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone.
|
||||
current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId);
|
||||
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
|
||||
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
|
||||
if (personId) current.board[slot] = { personId, name: '' };
|
||||
else delete current.board[slot];
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
@@ -756,7 +702,6 @@ module.exports = {
|
||||
const name = String(q.get('name') || '').trim();
|
||||
const existing = current.board[index] || { personId: '', name: '' };
|
||||
existing.name = name;
|
||||
existing.plan = current.planId();
|
||||
if (!existing.personId && !name) delete current.board[index];
|
||||
else current.board[index] = existing;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
|
||||
@@ -230,34 +230,3 @@
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
/* Drag: the floating photo that follows the pointer (appended to <body>, so it scopes itself). */
|
||||
[data-module="band-lineup"].bl-drag-ghost {
|
||||
position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none;
|
||||
width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden;
|
||||
border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92;
|
||||
}
|
||||
[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||
[data-module="band-lineup"] .bl-lib-item,
|
||||
[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
[data-module="band-lineup"] .bl-edit-slot img,
|
||||
[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; }
|
||||
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
|
||||
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
|
||||
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
|
||||
|
||||
/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
|
||||
[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
|
||||
[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
|
||||
[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
|
||||
[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
|
||||
[data-module="band-lineup"] .bl-lib-search {
|
||||
display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
|
||||
[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
|
||||
[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
|
||||
[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "resi-cues",
|
||||
"name": "Resi Cues",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||
"provides": ["timers"],
|
||||
|
||||
+18
-93
@@ -39,9 +39,6 @@ function msToPosition(total) {
|
||||
|
||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
||||
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
|
||||
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
|
||||
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
|
||||
|
||||
/**
|
||||
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||
@@ -64,8 +61,6 @@ function createPlayhead(options) {
|
||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
||||
let spreads = []; // how far apart honest readings sit: this link's noise
|
||||
let gaps = []; // how long between the playhead actually moving
|
||||
let changes = 0; // readings that differed from the one before
|
||||
let rejected = false; // the last gap looked like a pause and was skipped
|
||||
let base; // the trusted offset
|
||||
let lastMedia;
|
||||
let lastNewAt;
|
||||
@@ -95,25 +90,15 @@ function createPlayhead(options) {
|
||||
spreads = spreads.concat(-delta).slice(-20);
|
||||
}
|
||||
}
|
||||
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
|
||||
// from it to the next one is only a fraction of the real cadence. Learning
|
||||
// from it locked in a 2s cadence that rejected every honest 30s gap.
|
||||
if (lastNewAt !== undefined && changes > 0) {
|
||||
if (lastNewAt !== undefined) {
|
||||
const gap = t - lastNewAt;
|
||||
// The gap either side of a pause is not Resi's cadence — recording it
|
||||
// would inflate the estimate and make the next pause slow to notice.
|
||||
// But a pause is one gap; twice in a row is the cadence having changed.
|
||||
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
if (gap > 0 && gap < 60000) {
|
||||
if (!worst || gap <= worst * 3 || rejected) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
rejected = false;
|
||||
} else {
|
||||
rejected = true;
|
||||
}
|
||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) changes++;
|
||||
offsets = offsets.concat({ at: t, off });
|
||||
lastMedia = mediaMs;
|
||||
lastNewAt = t;
|
||||
@@ -133,9 +118,7 @@ function createPlayhead(options) {
|
||||
// Only call it paused once the playhead has been still for clearly longer than
|
||||
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
|
||||
// countdown that freezes mid-service is the worse failure of the two.
|
||||
// Until a full gap has been measured the cadence is unknown; assume the slowest
|
||||
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
|
||||
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
|
||||
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
|
||||
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||
if (!playing) {
|
||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||
@@ -178,43 +161,13 @@ function createResiClient(options) {
|
||||
return h;
|
||||
}
|
||||
|
||||
/* A failed login used to be retried on the very next poll -- once a second, for
|
||||
ever. Resi answers that with 429s, and since the retries never let up, the
|
||||
account never got out of them. So a failure now holds off further attempts,
|
||||
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
|
||||
call in between fails at once with the same reason instead of signing in. */
|
||||
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
||||
let loginFailures = 0;
|
||||
let loginBlockedUntil = 0;
|
||||
let loginError = '';
|
||||
|
||||
async function login() {
|
||||
if (now() < loginBlockedUntil) {
|
||||
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
||||
});
|
||||
} catch (err) {
|
||||
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
|
||||
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
|
||||
: `Resi login failed (${res.status})`;
|
||||
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||
holdOff(reason, retryAfter * 1000);
|
||||
throw new Error(reason);
|
||||
}
|
||||
loginFailures = 0;
|
||||
loginBlockedUntil = 0;
|
||||
loginError = '';
|
||||
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
||||
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
const jar = {};
|
||||
@@ -226,49 +179,24 @@ function createResiClient(options) {
|
||||
token = jar.livingas1 || '';
|
||||
if (!token) throw new Error('Resi login returned no session cookie');
|
||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||
loggedInAt = now();
|
||||
loggedInAt = Date.now();
|
||||
const me = await get('/api_v2.svc/users/me');
|
||||
customerId = (me && me.customerId) || '';
|
||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||
return me;
|
||||
}
|
||||
|
||||
function holdOff(reason, askedMs) {
|
||||
loginFailures++;
|
||||
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
|
||||
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
|
||||
loginError = reason;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
}
|
||||
|
||||
/* The same hold-off covers every request, not just the login. A 401/403 used to
|
||||
sign in again on the spot, every time: for a login that simply isn't allowed to
|
||||
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
|
||||
after signing in is taken as what it is -- a permission -- and held off like a
|
||||
failed login; only an older session is renewed. A 429 holds off too. */
|
||||
async function get(path) {
|
||||
if (now() < loginBlockedUntil) {
|
||||
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||
}
|
||||
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
|
||||
await login();
|
||||
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
}
|
||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const where = path.includes('/venues') ? 'its venues'
|
||||
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
|
||||
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
|
||||
throw new Error(loginError);
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
|
||||
throw new Error(loginError);
|
||||
await login();
|
||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
||||
return retry.json();
|
||||
}
|
||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
@@ -288,10 +216,7 @@ function createResiClient(options) {
|
||||
try {
|
||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||
} catch (err) {
|
||||
// Only an objection to the parameter itself; a refusal or a "slow down"
|
||||
// (401/403/429) is about the login, and dropping it then lost fresh readings.
|
||||
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
|
||||
if (!code || ['401', '403', '429'].includes(code)) throw err;
|
||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
|
||||
cacheBust = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,32 +197,6 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
|
||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
||||
});
|
||||
|
||||
/* ── starting partway through Resi's cycle ─────────────────────────────── */
|
||||
|
||||
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
|
||||
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
|
||||
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
|
||||
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
|
||||
// and the next cue (and its name) stopped moving on.
|
||||
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
|
||||
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
|
||||
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
|
||||
w.lastPublish = w.t - into;
|
||||
const head = createPlayhead({ now: () => w.t });
|
||||
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
|
||||
let paused = 0;
|
||||
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
|
||||
if (burst) {
|
||||
w.media += 15 * 60000; // a seek, published at once...
|
||||
w.lastPublish = w.t - w.nextCadence;
|
||||
second(); second();
|
||||
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
|
||||
}
|
||||
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
|
||||
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||
|
||||
const argv = process.argv.slice(2).map(Number);
|
||||
|
||||
@@ -70,7 +70,6 @@ function emptyTimers(now) {
|
||||
}
|
||||
|
||||
let current = null; // the live handle, shared with routes()
|
||||
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
@@ -147,9 +146,7 @@ module.exports = {
|
||||
elapsedMs: null,
|
||||
remainingMs,
|
||||
targetMs: Math.max(1000, state.next.ms - from),
|
||||
// The label already is the cue's name, and it is what the card is titled
|
||||
// with; repeating it on the status line only crowded the card.
|
||||
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
|
||||
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
|
||||
detail: state.event || '',
|
||||
});
|
||||
}
|
||||
@@ -274,7 +271,6 @@ module.exports = {
|
||||
stateStreams,
|
||||
timerStreams,
|
||||
resi,
|
||||
credentialsKey: `${username}\n${password}`,
|
||||
configured: Boolean(username && password),
|
||||
stop() {
|
||||
stopped = true;
|
||||
@@ -336,21 +332,9 @@ module.exports = {
|
||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||
}
|
||||
try {
|
||||
// The running module's client when it has these credentials, else one kept
|
||||
// per login: a fresh client per request signed in on every admin page load,
|
||||
// and its own hold-off never applied -- which kept Resi rate-limiting.
|
||||
const username = String(config.username).trim();
|
||||
const key = `${username}\n${config.password}`;
|
||||
let client;
|
||||
if (current && current.resi && current.credentialsKey === key) client = current.resi;
|
||||
else {
|
||||
if (!pickerClient || pickerClient.key !== key) {
|
||||
pickerClient = { key, client: createResiClient({
|
||||
username, password: String(config.password),
|
||||
}) };
|
||||
}
|
||||
client = pickerClient.client;
|
||||
}
|
||||
const client = createResiClient({
|
||||
username: String(config.username), password: String(config.password),
|
||||
});
|
||||
const options = [];
|
||||
for (const v of await client.venues()) {
|
||||
for (const p of await client.players(v.uuid)) {
|
||||
|
||||
@@ -18,9 +18,8 @@
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0;
|
||||
// Awaited, so an async test finishes before the next one changes the world under it.
|
||||
async function test(name, fn) {
|
||||
await fn();
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
@@ -102,7 +101,7 @@ function collect(route, req) {
|
||||
|
||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
||||
|
||||
await test('signs in once and finds the configured decoder', () => {
|
||||
test('signs in once and finds the configured decoder', () => {
|
||||
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.status, 'ok', state.message);
|
||||
@@ -111,20 +110,20 @@ function collect(route, req) {
|
||||
assert.strictEqual(state.cues.length, 3);
|
||||
});
|
||||
|
||||
await test('works out the cue either side of the playhead', () => {
|
||||
test('works out the cue either side of the playhead', () => {
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.next.name, 'Campus Host');
|
||||
assert.strictEqual(state.prev.name, 'Welcome');
|
||||
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
||||
});
|
||||
|
||||
await test('/state answers the same snapshot as the stream', () => {
|
||||
test('/state answers the same snapshot as the stream', () => {
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
||||
});
|
||||
|
||||
await test('/stream opens with an event so a new tile paints at once', () => {
|
||||
test('/stream opens with an event so a new tile paints at once', () => {
|
||||
const res = collect(routes['GET /stream']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
||||
@@ -132,7 +131,7 @@ function collect(route, req) {
|
||||
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
||||
});
|
||||
|
||||
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.source, 'resi-cues');
|
||||
assert.strictEqual(snap.label, 'Resi');
|
||||
@@ -146,7 +145,7 @@ function collect(route, req) {
|
||||
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
|
||||
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||
assert.strictEqual(next.targetMs, 450000);
|
||||
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
|
||||
assert.strictEqual(next.status.text, 'Campus Host');
|
||||
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||
|
||||
assert.strictEqual(playhead.id, 'playhead');
|
||||
@@ -155,7 +154,7 @@ function collect(route, req) {
|
||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
||||
});
|
||||
|
||||
await test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||
test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||
world.position = '00:12:40.000'; // jumped past Campus Host
|
||||
tickFn();
|
||||
await settle();
|
||||
@@ -163,10 +162,10 @@ function collect(route, req) {
|
||||
assert.strictEqual(state.next.name, 'Message');
|
||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
|
||||
assert.strictEqual(snap.timers[0].label, 'Message');
|
||||
});
|
||||
|
||||
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
world.eventId = '';
|
||||
tickFn();
|
||||
await settle();
|
||||
@@ -175,13 +174,13 @@ function collect(route, req) {
|
||||
assert.strictEqual(handle.state.event, '');
|
||||
});
|
||||
|
||||
await test('health speaks about the upstream, not about itself', () => {
|
||||
test('health speaks about the upstream, not about itself', () => {
|
||||
const h = handle.health();
|
||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||
assert.ok(h.message.length > 0, 'health should say something useful');
|
||||
});
|
||||
|
||||
await test('an unconfigured module idles instead of hammering Resi', () => {
|
||||
test('an unconfigured module idles instead of hammering Resi', () => {
|
||||
const before = world.logins;
|
||||
const idle = server.init({ config: {}, log: () => {} });
|
||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||
@@ -190,90 +189,12 @@ function collect(route, req) {
|
||||
idle.stop();
|
||||
});
|
||||
|
||||
await test('stop() leaves nothing running', () => {
|
||||
test('stop() leaves nothing running', () => {
|
||||
handle.stop();
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
||||
});
|
||||
|
||||
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
|
||||
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
|
||||
await test('a failed login holds off instead of retrying every poll', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
let t = 1700000000000;
|
||||
let logins = 0;
|
||||
let limited = true;
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c',
|
||||
password: 'secret',
|
||||
now: () => t,
|
||||
fetch: async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
logins++;
|
||||
if (limited) return reply({}, { ok: false, status: 429 });
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
},
|
||||
});
|
||||
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||
|
||||
assert.match(await poll(), /rate-limiting/);
|
||||
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
|
||||
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
|
||||
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
|
||||
|
||||
limited = false;
|
||||
t += 5 * 60000; // past the longest hold-off
|
||||
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
|
||||
const after = logins;
|
||||
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
|
||||
assert.strictEqual(logins, after, 'a good session is not signed in again');
|
||||
});
|
||||
|
||||
await test('a rejected password says so', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c', password: 'wrong', now: () => 0,
|
||||
fetch: async () => reply({}, { ok: false, status: 400 }),
|
||||
});
|
||||
await assert.rejects(client.venues(), /check the password/);
|
||||
});
|
||||
|
||||
// Seen live: media@waters.church signs in fine but may not list venues (403). The
|
||||
// client answered every 403 by signing in again -- a fresh login on every poll.
|
||||
await test('a login that may not see venues is held off, not signed in every poll', async () => {
|
||||
const { createResiClient } = require('./resi');
|
||||
let t = 1700000000000;
|
||||
let logins = 0;
|
||||
let venueCalls = 0;
|
||||
const client = createResiClient({
|
||||
username: 'a@b.c', password: 'secret', now: () => t,
|
||||
fetch: async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
logins++;
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
},
|
||||
});
|
||||
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||
|
||||
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
|
||||
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
|
||||
// At most one renewal once the session is over a minute old (in case it really
|
||||
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
|
||||
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
|
||||
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
|
||||
assert.match(await poll(), /ask a Resi admin/);
|
||||
});
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
globalThis.clearInterval = realClearInterval;
|
||||
console.log(`\n${passed} tests passed`);
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/* Vista Cues — client part.
|
||||
|
||||
One tile: the cue list most recently fired, big, with how long ago and
|
||||
who fired it; under it the other lists still live, the recent notes, and
|
||||
which senders we are in session with. All of it comes from the server's
|
||||
one `state` event; between events only the "how long ago" clocks move. */
|
||||
|
||||
const MINUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9"/></svg>';
|
||||
const PLUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9M8 3.5v9"/></svg>';
|
||||
const CLEAR_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 13.5h7.3"/><path d="M2.8 9.6 8.9 3.5a1.4 1.4 0 0 1 2 0l2.1 2.1a1.4 1.4 0 0 1 0 2l-5.9 5.9H5.6L2.8 10.7a.8.8 0 0 1 0-1.1Z"/><path d="m6 6.4 4.1 4.1"/></svg>';
|
||||
|
||||
const ACTION_LABEL = {
|
||||
go: 'Go',
|
||||
release: 'Release',
|
||||
releaseAll: 'Release all',
|
||||
pause: 'Pause',
|
||||
back: 'Back',
|
||||
flash: 'Flash',
|
||||
cleared: 'Cleared',
|
||||
'': '—',
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/** 75 000 ms → "1:15", 3 725 000 → "1:02:05". */
|
||||
function since(ms) {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||
}
|
||||
|
||||
function clockTime(ms) {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
const prefs = () => moduleApi.instanceSettings || {};
|
||||
const on = (k, dflt = true) => (prefs()[k] === undefined ? dflt : prefs()[k] !== false);
|
||||
const scale = () => Math.min(2.5, Math.max(0.5, Number(prefs().textScale) || 1));
|
||||
|
||||
let state = null;
|
||||
let skew = 0; // server clock − our clock
|
||||
let stream = null;
|
||||
let tick = null;
|
||||
let el = {};
|
||||
let resizeObserver = null;
|
||||
|
||||
const serverNow = () => Date.now() + skew;
|
||||
|
||||
/* ── fitting the big name to its box ─────────────────────────────── */
|
||||
|
||||
function fit(node) {
|
||||
if (!node || !node.isConnected) return;
|
||||
node.style.fontSize = '';
|
||||
const avail = node.parentElement ? node.parentElement.clientWidth : node.clientWidth;
|
||||
let size = parseFloat(getComputedStyle(node).fontSize) || 16;
|
||||
for (let i = 0; i < 3 && node.scrollWidth > avail + 0.5 && size > 10; i += 1) {
|
||||
size = Math.max(10, size * (avail / node.scrollWidth) * 0.98);
|
||||
node.style.fontSize = `${size}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── drawing ─────────────────────────────────────────────────────── */
|
||||
|
||||
function build() {
|
||||
root.innerHTML = `
|
||||
<div class="vc-root">
|
||||
<div class="vc-offline" hidden></div>
|
||||
<div class="vc-body">
|
||||
<div class="vc-now">
|
||||
<div class="vc-kicker"><span class="vc-pill"></span><span class="vc-ago"></span></div>
|
||||
<div class="vc-name-wrap"><div class="vc-name"></div></div>
|
||||
<div class="vc-meta"></div>
|
||||
</div>
|
||||
<div class="vc-active"></div>
|
||||
<div class="vc-recent"></div>
|
||||
<div class="vc-sources"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const q = (s) => root.querySelector(s);
|
||||
el = {
|
||||
root: q('.vc-root'),
|
||||
offline: q('.vc-offline'),
|
||||
pill: q('.vc-pill'),
|
||||
ago: q('.vc-ago'),
|
||||
name: q('.vc-name'),
|
||||
meta: q('.vc-meta'),
|
||||
active: q('.vc-active'),
|
||||
recent: q('.vc-recent'),
|
||||
sources: q('.vc-sources'),
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!el.root) return;
|
||||
el.root.style.setProperty('--vc-scale', String(scale()));
|
||||
const s = state;
|
||||
if (!s) {
|
||||
el.name.textContent = 'Connecting…';
|
||||
el.root.dataset.tone = 'muted';
|
||||
return;
|
||||
}
|
||||
|
||||
const connected = s.sources.filter((x) => x.connected);
|
||||
const offline = !s.listening || !connected.length;
|
||||
el.root.classList.toggle('is-offline', offline);
|
||||
el.offline.hidden = !offline;
|
||||
el.offline.textContent = !s.listening ? 'Not listening' : 'No MIDI session';
|
||||
|
||||
// Now
|
||||
const cur = s.current;
|
||||
if (cur) {
|
||||
el.root.dataset.tone = cur.state === 'paused' ? 'warn' : 'ok';
|
||||
el.pill.textContent = cur.state === 'paused' ? 'Paused' : 'Live';
|
||||
el.pill.hidden = false;
|
||||
if (el.name.textContent !== cur.name) { el.name.textContent = cur.name; fit(el.name); }
|
||||
const bits = [];
|
||||
if (cur.goes > 1) bits.push(`Go ×${cur.goes}`);
|
||||
if (cur.source) bits.push(cur.source);
|
||||
if (cur.lastAction === 'back') bits.push('Back');
|
||||
el.meta.textContent = bits.join(' · ');
|
||||
} else {
|
||||
el.root.dataset.tone = 'muted';
|
||||
el.pill.hidden = true;
|
||||
const msg = s.rules ? 'No cue list live' : 'No cue lists mapped';
|
||||
if (el.name.textContent !== msg) { el.name.textContent = msg; fit(el.name); }
|
||||
el.meta.textContent = s.rules ? (s.log.length ? '' : 'Waiting for the first cue') : 'Add the cue sheet in /admin → Vista Cues';
|
||||
}
|
||||
|
||||
// Other live lists
|
||||
el.active.hidden = !on('showActive') || !s.active.length;
|
||||
if (!el.active.hidden) {
|
||||
el.active.innerHTML = s.active.map((l) => `
|
||||
<div class="vc-chip${l.state === 'paused' ? ' is-paused' : ''}" title="${esc(l.source || '')}">
|
||||
${l.group ? `<span class="vc-chip-group">${esc(l.group)}</span>` : ''}
|
||||
<span class="vc-chip-name">${esc(l.name)}</span>
|
||||
<span class="vc-chip-ago" data-since="${l.lastAt}"></span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// Recent
|
||||
const showUnmapped = on('showUnmapped', false);
|
||||
const rows = s.log.filter((e) => e.mapped || showUnmapped).slice(0, 12);
|
||||
el.recent.hidden = !on('showRecent') || !rows.length;
|
||||
if (!el.recent.hidden) {
|
||||
el.recent.innerHTML = rows.map((e) => `
|
||||
<div class="vc-row${e.mapped ? '' : ' is-unmapped'}" data-action="${esc(e.action)}">
|
||||
<span class="vc-row-time">${esc(clockTime(e.at))}</span>
|
||||
<span class="vc-row-action">${esc(e.mapped ? (ACTION_LABEL[e.action] ?? e.action) : 'Unmapped')}</span>
|
||||
<span class="vc-row-list">${esc(e.mapped ? e.list : e.trigger)}</span>
|
||||
<span class="vc-row-source">${esc(e.source)}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// Senders
|
||||
el.sources.hidden = !on('showSources') || !s.sources.length;
|
||||
if (!el.sources.hidden) {
|
||||
el.sources.innerHTML = s.sources.map((x) => `
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
|
||||
<span class="vc-dot"></span>${esc(x.label)}
|
||||
</span>`).join('');
|
||||
}
|
||||
|
||||
updateClocks();
|
||||
reportStatus();
|
||||
}
|
||||
|
||||
function updateClocks() {
|
||||
if (!state || !el.root) return;
|
||||
const now = serverNow();
|
||||
const cur = state.current;
|
||||
el.ago.textContent = cur ? since(now - cur.lastAt) : '';
|
||||
el.ago.title = cur ? `Fired ${clockTime(cur.firedAt)}` : '';
|
||||
root.querySelectorAll('[data-since]').forEach((n) => {
|
||||
n.textContent = since(now - Number(n.dataset.since));
|
||||
});
|
||||
}
|
||||
|
||||
function reportStatus() {
|
||||
const s = state;
|
||||
if (!s) return;
|
||||
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
||||
const connected = s.sources.filter((x) => x.connected);
|
||||
const waiting = s.sources.filter((x) => !x.connected);
|
||||
const off = s.sources.filter((x) => x.offNetwork);
|
||||
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
|
||||
if (!connected.length) {
|
||||
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
||||
return moduleApi.setStatus('error', why);
|
||||
}
|
||||
const msg = `Hearing ${connected.map((x) => x.label).join(', ')}${waiting.length ? ` · not ${waiting.map((x) => x.label).join(', ')}` : ''}`;
|
||||
return moduleApi.setStatus(waiting.length ? 'connecting' : 'ok', msg);
|
||||
}
|
||||
|
||||
function take(next) {
|
||||
if (!next) return;
|
||||
skew = Number(next.now) ? Number(next.now) - Date.now() : 0;
|
||||
state = next;
|
||||
render();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await moduleApi.fetch('/state');
|
||||
if (r.ok) take((await r.json()).state);
|
||||
} catch { /* the stream will say so */ }
|
||||
}
|
||||
|
||||
/* ── title bar ───────────────────────────────────────────────────── */
|
||||
|
||||
function bump(delta) {
|
||||
const next = Math.round(Math.min(2.5, Math.max(0.5, scale() + delta)) * 10) / 10;
|
||||
moduleApi.saveInstanceSettings({ textScale: next });
|
||||
render();
|
||||
fit(el.name);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
build();
|
||||
render();
|
||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bump(-0.1) });
|
||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bump(0.1) });
|
||||
moduleApi.header.addButton({
|
||||
icon: CLEAR_SVG,
|
||||
title: 'Clear live lists',
|
||||
async onClick() {
|
||||
try { await moduleApi.fetch('/clear', { method: 'POST' }); } catch { /* status says why */ }
|
||||
},
|
||||
});
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() { refresh(); },
|
||||
error() { moduleApi.setStatus('error', 'Lost connection to ProdDash'); },
|
||||
events: {
|
||||
state(e) {
|
||||
try { take(JSON.parse(e.data)); } catch { /* malformed frame */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
tick = setInterval(updateClocks, 1000);
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
resizeObserver = new ResizeObserver(() => fit(el.name));
|
||||
resizeObserver.observe(root);
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
clearInterval(tick);
|
||||
tick = null;
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
|
||||
root.innerHTML = '';
|
||||
el = {};
|
||||
},
|
||||
onResize() {
|
||||
fit(el.name);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The admin page's "Cue lists" text → rules, and MIDI events → what Vista
|
||||
* was told to do.
|
||||
*
|
||||
* One rule per line, the way it reads on a cue sheet:
|
||||
*
|
||||
* 60 = Walk-in note 60 fires Go on "Walk-in"
|
||||
* C3 = Worship 1 note names work too (see "Middle C" in admin)
|
||||
* 61 release Walk-in release a list
|
||||
* 62 pause Walk-in also: back, pause, go (the default)
|
||||
* 127 release all every list off
|
||||
* ch2:64 = Message only on MIDI channel 2 (otherwise any channel)
|
||||
* pc5 = Baptism a program change instead of a note
|
||||
* 65 = "Back Lights" quote a name that starts with an action word
|
||||
* # anything after a # is a comment
|
||||
*
|
||||
* Groups: a line "[Colors]" starts a group, and every rule below it belongs
|
||||
* to it until the next "[…]" ("[]" ends grouping). A Go in a group replaces
|
||||
* whichever list of that group was live — firing "Blue" ends "Red" — which is
|
||||
* how a sheet without release notes still says what is on. The first group
|
||||
* in the sheet is the headline the tile shows largest.
|
||||
*
|
||||
* 30 flash Blinder Flash 1 a momentary hit: logged, never left live
|
||||
*
|
||||
* The separator between the trigger and the rest may be "=", "-", ":", "→",
|
||||
* a tab or just a space, so a column pasted from a spreadsheet works.
|
||||
*/
|
||||
|
||||
const NOTE_NAMES = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
||||
const NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
const ACTIONS = [
|
||||
// Longest first, so "release all" wins over "release".
|
||||
[/^(release[\s_-]*all|all[\s_-]*off|releaseall|clear)\b/i, 'releaseAll'],
|
||||
[/^(release|off|stop|kill)\b/i, 'release'],
|
||||
[/^(pause|hold)\b/i, 'pause'],
|
||||
[/^(back|previous|prev)\b/i, 'back'],
|
||||
[/^(flash|hit|momentary)\b/i, 'flash'],
|
||||
[/^(go|fire|play|start)\b/i, 'go'],
|
||||
];
|
||||
|
||||
/** Octave offset: with middle C (note 60) called "C3", C-2 is note 0; with "C4", C-1 is. */
|
||||
function octaveBase(middleC) {
|
||||
return middleC === 'C4' ? 1 : 2;
|
||||
}
|
||||
|
||||
/** "F#2" → note number, or null. */
|
||||
function noteFromName(token, middleC) {
|
||||
const m = /^([a-g])(#|♯|b|♭)?(-?\d+)$/i.exec(token);
|
||||
if (!m) return null;
|
||||
let pc = NOTE_NAMES[m[1].toLowerCase()];
|
||||
if (m[2] === '#' || m[2] === '♯') pc += 1;
|
||||
if (m[2] === 'b' || m[2] === '♭') pc -= 1;
|
||||
const n = (Number(m[3]) + octaveBase(middleC)) * 12 + pc;
|
||||
return n >= 0 && n <= 127 ? n : null;
|
||||
}
|
||||
|
||||
function noteName(n, middleC) {
|
||||
return `${NAMES_SHARP[n % 12]}${Math.floor(n / 12) - octaveBase(middleC)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the whole text. Returns `{ rules, errors, groups }` (groups in sheet order); a line that can't be read
|
||||
* is reported (line number + why) and skipped, never fatal.
|
||||
*/
|
||||
function parseMapping(text, { middleC = 'C3' } = {}) {
|
||||
const rules = [];
|
||||
const errors = [];
|
||||
const groups = [];
|
||||
let group = '';
|
||||
String(text || '').split(/\r?\n/).forEach((raw, i) => {
|
||||
// A comment is a # that starts the line or follows a space ("C#3" and "Song #2" are not).
|
||||
const line = raw.replace(/(^|\s)#(\s.*|$)/, '').trim();
|
||||
if (!line) return;
|
||||
const header = /^\[(.*)\]$/.exec(line);
|
||||
if (header) {
|
||||
group = header[1].trim();
|
||||
if (group && !groups.includes(group)) groups.push(group);
|
||||
return;
|
||||
}
|
||||
const m = /^(?:ch(?:annel)?\s*(\d{1,2})\s*[:/]\s*)?(pc\s*\d{1,3}|note\s*\d{1,3}|\d{1,3}|[a-g](?:#|♯|b|♭)?-?\d)\s*(?:[=:\t→>|,-]+\s*|\s+|$)(.*)$/i.exec(line);
|
||||
if (!m) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'starts with no note' });
|
||||
return;
|
||||
}
|
||||
const channel = m[1] ? Number(m[1]) : null;
|
||||
if (channel != null && (channel < 1 || channel > 16)) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'channel must be 1–16' });
|
||||
return;
|
||||
}
|
||||
const trig = m[2].replace(/\s+/g, '').toLowerCase();
|
||||
let kind = 'note';
|
||||
let number;
|
||||
if (trig.startsWith('pc')) {
|
||||
kind = 'program';
|
||||
number = Number(trig.slice(2));
|
||||
} else if (trig.startsWith('note')) {
|
||||
number = Number(trig.slice(4));
|
||||
} else if (/^\d+$/.test(trig)) {
|
||||
number = Number(trig);
|
||||
} else {
|
||||
number = noteFromName(m[2].replace(/\s+/g, ''), middleC);
|
||||
}
|
||||
if (number == null || !Number.isInteger(number) || number < 0 || number > 127) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'note must be 0–127' });
|
||||
return;
|
||||
}
|
||||
let rest = m[3].trim();
|
||||
let action = 'go';
|
||||
const quoted = (t) => /^"(.*)"$/.exec(t);
|
||||
for (const [re, a] of quoted(rest) ? [] : ACTIONS) {
|
||||
const am = re.exec(rest);
|
||||
if (am) {
|
||||
action = a;
|
||||
rest = rest.slice(am[0].length).replace(/^[\s=:→>|,-]+/, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const q = quoted(rest);
|
||||
if (q) rest = q[1].trim();
|
||||
if (action !== 'releaseAll' && !rest) {
|
||||
errors.push({ line: i + 1, text: raw.trim(), why: 'no cue list name' });
|
||||
return;
|
||||
}
|
||||
rules.push({ line: i + 1, channel, kind, number, action, list: action === 'releaseAll' ? '' : rest, group });
|
||||
});
|
||||
return { rules, errors, groups };
|
||||
}
|
||||
|
||||
/** The first rule a MIDI event matches, or null. Only note-ons and program changes fire anything. */
|
||||
function match(rules, event) {
|
||||
let kind;
|
||||
let number;
|
||||
if (event.type === 'noteon') { kind = 'note'; number = event.note; }
|
||||
else if (event.type === 'program') { kind = 'program'; number = event.program; }
|
||||
else return null;
|
||||
return rules.find((r) => r.kind === kind && r.number === number && (r.channel == null || r.channel === event.channel)) || null;
|
||||
}
|
||||
|
||||
/** "Note 60 (C3) · ch 1" — how an event is named on the tile. */
|
||||
function triggerLabel(event, middleC) {
|
||||
if (event.type === 'program') return `PC ${event.program} · ch ${event.channel}`;
|
||||
return `Note ${event.note} (${noteName(event.note, middleC)}) · ch ${event.channel}`;
|
||||
}
|
||||
|
||||
module.exports = { parseMapping, match, noteName, noteFromName, triggerLabel };
|
||||
@@ -1,173 +0,0 @@
|
||||
{
|
||||
"id": "vista-cues",
|
||||
"name": "Vista Cues",
|
||||
"version": "1.1.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Joining the network MIDI sessions that fire Vista's cue lists"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 5,
|
||||
"h": 4
|
||||
},
|
||||
"configSchema": {
|
||||
"source1Name": {
|
||||
"type": "string",
|
||||
"label": "Name",
|
||||
"default": "ProPresenter",
|
||||
"group": "First sender",
|
||||
"help": "What the tile calls notes from this machine."
|
||||
},
|
||||
"source1": {
|
||||
"type": "endpoint",
|
||||
"label": "Session",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 5004
|
||||
},
|
||||
"group": "First sender",
|
||||
"help": "The Mac whose network MIDI session sends Vista its notes, and that session's port (Audio MIDI Setup → Window → Show MIDI Studio → Network; usually 5004)."
|
||||
},
|
||||
"source2Name": {
|
||||
"type": "string",
|
||||
"label": "Name",
|
||||
"default": "Tracks",
|
||||
"group": "Second sender"
|
||||
},
|
||||
"source2": {
|
||||
"type": "endpoint",
|
||||
"label": "Session",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 5004
|
||||
},
|
||||
"group": "Second sender",
|
||||
"help": "Leave the host empty if this machine joins us from its own side instead."
|
||||
},
|
||||
"source3Name": {
|
||||
"type": "string",
|
||||
"label": "Name",
|
||||
"default": "Companion",
|
||||
"group": "Third sender"
|
||||
},
|
||||
"source3": {
|
||||
"type": "endpoint",
|
||||
"label": "Session",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 5004
|
||||
},
|
||||
"group": "Third sender",
|
||||
"help": "Companion on this machine counts too: its network session's host and port (this Mac's own name works)."
|
||||
},
|
||||
"cueLists": {
|
||||
"type": "text",
|
||||
"label": "Cue lists",
|
||||
"default": "",
|
||||
"group": "Cue lists",
|
||||
"help": "One per line from the cue sheet: 60 = Walk-in · 61 release Walk-in · 127 release all · 30 flash Blinder 1 (momentary). A line like [Colors] starts a group: a Go in a group replaces that group's live list; the first group is the tile's headline. Also pause, back, note names, ch2:64, pc5, and \"quotes\" around a name starting with one of those words."
|
||||
},
|
||||
"middleC": {
|
||||
"type": "select",
|
||||
"label": "Middle C",
|
||||
"default": "C3",
|
||||
"options": [
|
||||
{
|
||||
"value": "C3",
|
||||
"label": "C3 (Ableton, Yamaha)"
|
||||
},
|
||||
{
|
||||
"value": "C4",
|
||||
"label": "C4 (Logic, scientific)"
|
||||
}
|
||||
],
|
||||
"group": "Cue lists",
|
||||
"help": "Only matters for note names in the sheet. Note 60 is middle C either way."
|
||||
},
|
||||
"headTakesOver": {
|
||||
"type": "switch",
|
||||
"label": "Main group takes over",
|
||||
"default": true,
|
||||
"group": "Cue lists",
|
||||
"help": "On: a Go in the sheet's first [group] (the main scene) clears every other live list, as a new scene takes over the look. FX fired after it layer on top again."
|
||||
},
|
||||
"exclusive": {
|
||||
"type": "switch",
|
||||
"label": "Go releases others",
|
||||
"default": false,
|
||||
"group": "Cue lists",
|
||||
"help": "On: a Go replaces every other live list, groups or not. Leave off when lists stack, and use groups instead."
|
||||
},
|
||||
"sessionName": {
|
||||
"type": "string",
|
||||
"label": "Session name",
|
||||
"default": "ProdDash Lighting",
|
||||
"group": "This machine",
|
||||
"help": "How ProdDash appears in other Macs' Audio MIDI Setup Directory."
|
||||
},
|
||||
"sessionPort": {
|
||||
"type": "number",
|
||||
"label": "Session port",
|
||||
"default": 5040,
|
||||
"min": 1024,
|
||||
"max": 65534,
|
||||
"group": "This machine",
|
||||
"help": "UDP; this one and the next are used. Anything not already taken by a MIDI session on this machine."
|
||||
},
|
||||
"advertise": {
|
||||
"type": "switch",
|
||||
"label": "Bonjour",
|
||||
"default": true,
|
||||
"group": "This machine",
|
||||
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
|
||||
},
|
||||
"wiredNetwork": {
|
||||
"type": "string",
|
||||
"label": "Wired network",
|
||||
"default": "",
|
||||
"group": "This machine",
|
||||
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
|
||||
}
|
||||
},
|
||||
"configGroups": {
|
||||
"First sender": {
|
||||
"help": "ProdDash joins the senders' network MIDI sessions, like Vista does, so it hears every note Vista hears. On each sender, in Audio MIDI Setup → MIDI Network Setup, the session must be enabled and 'Who may connect to me' set to Anyone. Or skip the address and connect to \"ProdDash Lighting\" from that Mac's Directory instead."
|
||||
},
|
||||
"This machine": {
|
||||
"collapsed": true
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showActive": {
|
||||
"type": "switch",
|
||||
"label": "Other lists",
|
||||
"default": true
|
||||
},
|
||||
"showRecent": {
|
||||
"type": "switch",
|
||||
"label": "Recent",
|
||||
"default": true
|
||||
},
|
||||
"showSources": {
|
||||
"type": "switch",
|
||||
"label": "Senders",
|
||||
"default": true
|
||||
},
|
||||
"showUnmapped": {
|
||||
"type": "switch",
|
||||
"label": "Unmapped notes",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,630 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A listen-only RTP-MIDI (AppleMIDI) session participant, in plain Node.
|
||||
*
|
||||
* This is the protocol behind a macOS "Network" MIDI session (Audio MIDI
|
||||
* Setup → MIDI Network Setup) and rtpMIDI on Windows: two UDP ports, a
|
||||
* control port P and a data port P+1. A session sends every MIDI message to
|
||||
* every participant it is connected to, so joining the same session Vista is
|
||||
* in hears exactly the notes Vista hears.
|
||||
*
|
||||
* Both ways of joining work:
|
||||
* - someone invites us (we show up in Audio MIDI Setup's Directory, or they
|
||||
* add our address by hand) and we accept anyone;
|
||||
* - we invite them (`invite(host, port)`), and keep re-inviting until they
|
||||
* accept, then keep the connection alive with clock syncs.
|
||||
*
|
||||
* We never send MIDI. Packet layouts follow Apple's AppleMIDI session
|
||||
* protocol and RFC 6295 (RTP payload for MIDI).
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
const dns = require('dns');
|
||||
const crypto = require('crypto');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const SIG = 0xffff;
|
||||
const CMD = { IN: 0x494e, OK: 0x4f4b, NO: 0x4e4f, BY: 0x4259, CK: 0x434b, RS: 0x5253 };
|
||||
const PROTOCOL_VERSION = 2;
|
||||
|
||||
const INVITE_RETRY_MS = 1500; // between invitation attempts while setting up
|
||||
const INVITE_ATTEMPTS = 12; // then give up for a while…
|
||||
const REINVITE_MS = 5000; // …and start over after this long
|
||||
const SYNC_FAST_MS = 1500; // first few clock syncs, close together
|
||||
const SYNC_MS = 10000; // then this often, as macOS does
|
||||
const SYNC_TIMEOUT_MS = 25000; // no sync answer for this long → connection is gone
|
||||
const IDLE_MS = 90000; // an inbound participant we have not heard from at all
|
||||
const FEEDBACK_MS = 1000; // receiver feedback (lets the sender trim its journal)
|
||||
|
||||
/** The session clock: 100 µs ticks since this process started the session. */
|
||||
function makeClock() {
|
||||
const t0 = process.hrtime.bigint();
|
||||
return () => (process.hrtime.bigint() - t0) / 100000n;
|
||||
}
|
||||
|
||||
function randomU32() {
|
||||
return crypto.randomBytes(4).readUInt32BE(0);
|
||||
}
|
||||
|
||||
/* ── packets ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function sessionPacket(cmd, token, ssrc, name) {
|
||||
const nameBuf = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`, 'utf8');
|
||||
const b = Buffer.alloc(16 + nameBuf.length);
|
||||
b.writeUInt16BE(SIG, 0);
|
||||
b.writeUInt16BE(cmd, 2);
|
||||
b.writeUInt32BE(PROTOCOL_VERSION, 4);
|
||||
b.writeUInt32BE(token >>> 0, 8);
|
||||
b.writeUInt32BE(ssrc >>> 0, 12);
|
||||
nameBuf.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
function syncPacket(ssrc, count, ts) {
|
||||
const b = Buffer.alloc(36);
|
||||
b.writeUInt16BE(SIG, 0);
|
||||
b.writeUInt16BE(CMD.CK, 2);
|
||||
b.writeUInt32BE(ssrc >>> 0, 4);
|
||||
b.writeUInt8(count, 8);
|
||||
for (let i = 0; i < 3; i++) b.writeBigUInt64BE(BigInt.asUintN(64, ts[i] || 0n), 12 + i * 8);
|
||||
return b;
|
||||
}
|
||||
|
||||
function feedbackPacket(ssrc, seq) {
|
||||
const b = Buffer.alloc(12);
|
||||
b.writeUInt16BE(SIG, 0);
|
||||
b.writeUInt16BE(CMD.RS, 2);
|
||||
b.writeUInt32BE(ssrc >>> 0, 4);
|
||||
b.writeUInt16BE(seq & 0xffff, 8);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** A session command (IN/OK/NO/BY/CK/RS), or null when it isn't one. */
|
||||
function parseSession(buf) {
|
||||
if (buf.length < 4 || buf.readUInt16BE(0) !== SIG) return null;
|
||||
const cmd = buf.readUInt16BE(2);
|
||||
if (cmd === CMD.CK) {
|
||||
if (buf.length < 36) return null;
|
||||
return {
|
||||
cmd,
|
||||
ssrc: buf.readUInt32BE(4),
|
||||
count: buf.readUInt8(8),
|
||||
ts: [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)],
|
||||
};
|
||||
}
|
||||
if (cmd === CMD.RS) {
|
||||
if (buf.length < 12) return null;
|
||||
return { cmd, ssrc: buf.readUInt32BE(4), seq: buf.readUInt16BE(8) };
|
||||
}
|
||||
if (cmd === CMD.IN || cmd === CMD.OK || cmd === CMD.NO || cmd === CMD.BY) {
|
||||
if (buf.length < 16) return null;
|
||||
let name = '';
|
||||
if (buf.length > 16) {
|
||||
const end = buf.indexOf(0, 16);
|
||||
name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
}
|
||||
return { cmd, version: buf.readUInt32BE(4), token: buf.readUInt32BE(8), ssrc: buf.readUInt32BE(12), name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Data bytes that follow a status byte (sysex is handled separately). */
|
||||
function dataLength(status) {
|
||||
const hi = status & 0xf0;
|
||||
if (hi === 0xc0 || hi === 0xd0) return 1;
|
||||
if (hi < 0xf0) return 2;
|
||||
if (status === 0xf1 || status === 0xf3) return 1;
|
||||
if (status === 0xf2) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MIDI commands in one RTP-MIDI packet (RFC 6295 §3), as
|
||||
* `{ status, data: [..] }` in order. Journal data is ignored: we only show
|
||||
* what is happening, and a note lost on the wire is one Vista also missed.
|
||||
*/
|
||||
function parseRtpMidi(buf) {
|
||||
if (buf.length < 13 || (buf[0] >> 6) !== 2) return null;
|
||||
const cc = buf[0] & 0x0f;
|
||||
const ext = Boolean(buf[0] & 0x10);
|
||||
const seq = buf.readUInt16BE(2);
|
||||
const ssrc = buf.readUInt32BE(8);
|
||||
let off = 12 + cc * 4;
|
||||
if (ext) {
|
||||
if (buf.length < off + 4) return null;
|
||||
off += 4 + buf.readUInt16BE(off + 2) * 4;
|
||||
}
|
||||
if (off >= buf.length) return null;
|
||||
const header = buf[off];
|
||||
const big = Boolean(header & 0x80);
|
||||
const zero = Boolean(header & 0x20);
|
||||
let len = header & 0x0f;
|
||||
if (big) {
|
||||
if (off + 1 >= buf.length) return null;
|
||||
len = (len << 8) | buf[off + 1];
|
||||
off += 2;
|
||||
} else {
|
||||
off += 1;
|
||||
}
|
||||
const end = Math.min(buf.length, off + len);
|
||||
const messages = [];
|
||||
let running = 0;
|
||||
let first = true;
|
||||
while (off < end) {
|
||||
if (!first || zero) {
|
||||
// Delta time: 1–4 octets, high bit means "more follow".
|
||||
let n = 0;
|
||||
while (off < end && (buf[off] & 0x80) && n < 3) { off++; n++; }
|
||||
off++;
|
||||
if (off >= end) break;
|
||||
}
|
||||
first = false;
|
||||
let status = buf[off];
|
||||
if (status & 0x80) {
|
||||
off++;
|
||||
} else if (running) {
|
||||
status = running;
|
||||
} else {
|
||||
break; // data byte with nothing to run on: give up on this packet
|
||||
}
|
||||
if (status === 0xf0 || status === 0xf7) {
|
||||
// A sysex, or a segment of one: runs to F7 (end), F0 (more follows) or F4 (cancelled).
|
||||
const start = off;
|
||||
while (off < end && buf[off] !== 0xf7 && buf[off] !== 0xf0 && buf[off] !== 0xf4) off++;
|
||||
const data = Array.from(buf.subarray(start, off));
|
||||
off++;
|
||||
messages.push({ status, data });
|
||||
running = 0;
|
||||
continue;
|
||||
}
|
||||
if (status < 0xf0) running = status;
|
||||
else if (status < 0xf8) running = 0; // system common cancels running status; realtime doesn't
|
||||
const n = dataLength(status);
|
||||
if (off + n > end) break;
|
||||
messages.push({ status, data: Array.from(buf.subarray(off, off + n)) });
|
||||
off += n;
|
||||
}
|
||||
return { seq, ssrc, messages };
|
||||
}
|
||||
|
||||
/** `{ status, data }` → a plain event, or null for what nobody here cares about. */
|
||||
function describe(msg) {
|
||||
const hi = msg.status & 0xf0;
|
||||
const channel = (msg.status & 0x0f) + 1;
|
||||
if (hi === 0x90 && msg.data[1] > 0) return { type: 'noteon', channel, note: msg.data[0], velocity: msg.data[1] };
|
||||
if (hi === 0x80 || (hi === 0x90 && msg.data[1] === 0)) return { type: 'noteoff', channel, note: msg.data[0] };
|
||||
if (hi === 0xc0) return { type: 'program', channel, program: msg.data[0] };
|
||||
if (hi === 0xb0) return { type: 'cc', channel, controller: msg.data[0], value: msg.data[1] };
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── the session ─────────────────────────────────────────────────────── */
|
||||
|
||||
class Session extends EventEmitter {
|
||||
/**
|
||||
* @param {{ name: string, port: number, log?: Function }} opts
|
||||
* Events: 'midi' ({ participant, event }), 'change' (participants or
|
||||
* listening changed), 'listening' (both ports bound).
|
||||
*/
|
||||
constructor({ name, port, log }) {
|
||||
super();
|
||||
this.name = name || 'ProdDash';
|
||||
this.port = port;
|
||||
this.log = log || (() => {});
|
||||
this.ssrc = randomU32();
|
||||
this.now = makeClock();
|
||||
this.control = null;
|
||||
this.data = null;
|
||||
this.participants = new Map(); // key → participant
|
||||
this.invites = new Map(); // key "host:port" → outbound invitation
|
||||
this.strangers = new Map(); // address → when we last told it we're not in its session
|
||||
this.timers = new Set();
|
||||
this.stopped = false;
|
||||
this.error = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Open both ports. Resolves once both are bound — or once the first try
|
||||
* failed, with `error` set. A port that is busy (our own previous session
|
||||
* still closing after a config save, or another program) is retried every
|
||||
* few seconds for as long as the session runs; 'listening' fires when it
|
||||
* finally binds.
|
||||
*/
|
||||
start() {
|
||||
this.every(FEEDBACK_MS, () => this.sendFeedback());
|
||||
this.every(5000, () => this.reap());
|
||||
return new Promise((resolve) => {
|
||||
let first = true;
|
||||
const attempt = (delay) => this.later(delay, async () => {
|
||||
const ok = await this.bind();
|
||||
if (ok) {
|
||||
this.error = '';
|
||||
this.emit('listening');
|
||||
this.emit('change');
|
||||
} else {
|
||||
this.emit('change');
|
||||
attempt(first ? 250 : 3000);
|
||||
}
|
||||
if (first) { first = false; resolve(ok); }
|
||||
});
|
||||
attempt(0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Bind control and data sockets together; on failure close both. */
|
||||
bind() {
|
||||
const open = (port, which) => new Promise((resolve) => {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
const fail = (e) => {
|
||||
try { sock.close(); } catch { /* not open */ }
|
||||
resolve({ sock: null, port, e });
|
||||
};
|
||||
sock.once('error', fail);
|
||||
sock.bind(port, () => {
|
||||
sock.removeListener('error', fail);
|
||||
sock.on('error', (e) => this.log(`socket error on ${port}: ${e.message}`));
|
||||
sock.on('message', (buf, rinfo) => {
|
||||
try { this.onPacket(buf, rinfo, which); } catch (e) { this.log(`bad packet from ${rinfo.address}: ${e.message}`); }
|
||||
});
|
||||
resolve({ sock, port });
|
||||
});
|
||||
});
|
||||
return Promise.all([open(this.port, 'control'), open(this.port + 1, 'data')]).then(([c, d]) => {
|
||||
if (this.stopped || !c.sock || !d.sock) {
|
||||
for (const x of [c, d]) if (x.sock) { try { x.sock.close(); } catch { /* closed */ } }
|
||||
const bad = !c.sock ? c : d;
|
||||
if (bad.e) {
|
||||
const msg = bad.e.code === 'EADDRINUSE'
|
||||
? `UDP port ${bad.port} is in use on this machine — pick another session port in /admin`
|
||||
: `UDP port ${bad.port}: ${bad.e.message}`;
|
||||
if (msg !== this.error) this.log(msg);
|
||||
this.error = msg;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.control = c.sock;
|
||||
this.data = d.sock;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
for (const t of this.timers) { clearTimeout(t); clearInterval(t); }
|
||||
this.timers.clear();
|
||||
const control = this.control;
|
||||
const data = this.data;
|
||||
const close = () => {
|
||||
for (const s of [control, data]) { try { s && s.close(); } catch { /* closed */ } }
|
||||
};
|
||||
// Say goodbye, then close once the goodbyes have left (a config save
|
||||
// re-inits straight away and wants these ports back).
|
||||
const byes = [...this.participants.values()].filter((p) => p.connected);
|
||||
let left = byes.length;
|
||||
if (!control || !left) close();
|
||||
else {
|
||||
for (const p of byes) {
|
||||
try {
|
||||
control.send(sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address, () => { if (--left === 0) close(); });
|
||||
} catch { if (--left === 0) close(); }
|
||||
}
|
||||
}
|
||||
this.control = null;
|
||||
this.data = null;
|
||||
this.participants.clear();
|
||||
this.invites.clear();
|
||||
}
|
||||
|
||||
every(ms, fn) {
|
||||
const t = setInterval(fn, ms);
|
||||
t.unref?.();
|
||||
this.timers.add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
later(ms, fn) {
|
||||
const t = setTimeout(() => { this.timers.delete(t); if (!this.stopped) fn(); }, ms);
|
||||
t.unref?.();
|
||||
this.timers.add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
send(sock, buf, port, address) {
|
||||
if (!sock || this.stopped) return;
|
||||
try { sock.send(buf, port, address); } catch { /* socket closed */ }
|
||||
}
|
||||
|
||||
/** Every participant, for display. */
|
||||
list() {
|
||||
return [...this.participants.values()].map((p) => ({
|
||||
name: p.name,
|
||||
address: p.address,
|
||||
port: p.controlPort,
|
||||
connected: p.connected,
|
||||
outbound: p.outbound,
|
||||
lastHeard: p.lastHeard || null,
|
||||
since: p.since || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/* — outbound invitations — */
|
||||
|
||||
/**
|
||||
* Keep a connection to the session at host:port. Re-invites on its own
|
||||
* whenever the connection drops. Returns the invitation's state object.
|
||||
*/
|
||||
invite(host, port, label) {
|
||||
const key = `${host}:${port}`;
|
||||
if (this.invites.has(key)) return this.invites.get(key);
|
||||
const inv = { key, host, port, label: label || host, address: '', token: 0, attempts: 0, stage: 'idle', error: '', timer: null };
|
||||
this.invites.set(key, inv);
|
||||
this.beginInvite(inv);
|
||||
return inv;
|
||||
}
|
||||
|
||||
beginInvite(inv) {
|
||||
if (this.stopped || !this.invites.has(inv.key)) return;
|
||||
dns.lookup(inv.host, { family: 4 }, (err, address) => {
|
||||
if (this.stopped) return;
|
||||
if (err) {
|
||||
inv.error = `can't find ${inv.host}`;
|
||||
inv.stage = 'idle';
|
||||
this.emit('change');
|
||||
this.later(REINVITE_MS, () => this.beginInvite(inv));
|
||||
return;
|
||||
}
|
||||
inv.address = address;
|
||||
// Already in a session with that machine (it invited us)? Nothing to do.
|
||||
const existing = this.findByAddress(address, inv.port);
|
||||
if (existing && existing.connected) {
|
||||
existing.label = inv.label;
|
||||
inv.stage = 'connected';
|
||||
inv.error = '';
|
||||
this.emit('change');
|
||||
return;
|
||||
}
|
||||
inv.token = randomU32();
|
||||
inv.attempts = 0;
|
||||
inv.stage = 'control';
|
||||
this.sendInvite(inv);
|
||||
});
|
||||
}
|
||||
|
||||
sendInvite(inv) {
|
||||
if (this.stopped || !this.invites.has(inv.key)) return;
|
||||
if (inv.stage !== 'control' && inv.stage !== 'data') return;
|
||||
if (inv.attempts >= INVITE_ATTEMPTS) {
|
||||
inv.error = inv.error || 'no answer';
|
||||
inv.stage = 'idle';
|
||||
this.emit('change');
|
||||
this.later(REINVITE_MS, () => this.beginInvite(inv));
|
||||
return;
|
||||
}
|
||||
inv.attempts++;
|
||||
const sock = inv.stage === 'control' ? this.control : this.data;
|
||||
const port = inv.stage === 'control' ? inv.port : inv.port + 1;
|
||||
this.send(sock, sessionPacket(CMD.IN, inv.token, this.ssrc, this.name), port, inv.address);
|
||||
clearTimeout(inv.timer);
|
||||
inv.timer = this.later(INVITE_RETRY_MS, () => this.sendInvite(inv));
|
||||
}
|
||||
|
||||
inviteFor(rinfo, token) {
|
||||
for (const inv of this.invites.values()) {
|
||||
if (inv.token === token && inv.address === rinfo.address) return inv;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findByAddress(address, controlPort) {
|
||||
for (const p of this.participants.values()) {
|
||||
if (p.address === address && (controlPort == null || p.controlPort === controlPort)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* — packets in — */
|
||||
|
||||
onPacket(buf, rinfo, which) {
|
||||
const s = parseSession(buf);
|
||||
if (!s) {
|
||||
if (which === 'data') this.onRtp(buf, rinfo);
|
||||
return;
|
||||
}
|
||||
const sock = which === 'control' ? this.control : this.data;
|
||||
switch (s.cmd) {
|
||||
case CMD.IN: return this.onInvitation(s, rinfo, which, sock);
|
||||
case CMD.OK: return this.onAccepted(s, rinfo, which);
|
||||
case CMD.NO: return this.onRejected(s, rinfo);
|
||||
case CMD.BY: return this.onBye(s, rinfo);
|
||||
case CMD.CK: return this.onSync(s, rinfo);
|
||||
default: return undefined; // RS from a peer: nothing for a listener to do
|
||||
}
|
||||
}
|
||||
|
||||
onInvitation(s, rinfo, which, sock) {
|
||||
// We accept anyone: being in more sessions only means hearing more cues.
|
||||
this.send(sock, sessionPacket(CMD.OK, s.token, this.ssrc, this.name), rinfo.port, rinfo.address);
|
||||
const key = `in:${s.ssrc}`;
|
||||
let p = this.participants.get(key);
|
||||
if (which === 'control') {
|
||||
if (!p) {
|
||||
p = {
|
||||
key, ssrc: s.ssrc, token: s.token, name: s.name || rinfo.address, label: '',
|
||||
address: rinfo.address, controlPort: rinfo.port, dataPort: rinfo.port + 1,
|
||||
outbound: false, connected: false, lastSeq: -1, fedBack: -1, lastHeard: Date.now(), since: 0,
|
||||
};
|
||||
this.participants.set(key, p);
|
||||
this.log(`invited by "${p.name}" at ${rinfo.address}:${rinfo.port}`);
|
||||
} else {
|
||||
p.token = s.token;
|
||||
p.controlPort = rinfo.port;
|
||||
p.name = s.name || p.name;
|
||||
}
|
||||
} else if (p) {
|
||||
p.dataPort = rinfo.port;
|
||||
p.connected = true;
|
||||
p.since = Date.now();
|
||||
p.lastHeard = Date.now();
|
||||
this.log(`in session with "${p.name}" (${p.address})`);
|
||||
for (const inv of this.invites.values()) {
|
||||
if (inv.address === p.address && inv.port === p.controlPort) {
|
||||
p.label = inv.label;
|
||||
inv.stage = 'connected';
|
||||
inv.error = '';
|
||||
clearTimeout(inv.timer);
|
||||
}
|
||||
}
|
||||
this.emit('change');
|
||||
}
|
||||
}
|
||||
|
||||
onAccepted(s, rinfo, which) {
|
||||
const inv = this.inviteFor(rinfo, s.token);
|
||||
if (!inv) return;
|
||||
clearTimeout(inv.timer);
|
||||
if (which === 'control' && inv.stage === 'control') {
|
||||
inv.stage = 'data';
|
||||
inv.attempts = 0;
|
||||
inv.remoteName = s.name;
|
||||
this.sendInvite(inv);
|
||||
return;
|
||||
}
|
||||
if (which === 'data' && inv.stage === 'data') {
|
||||
const key = `out:${inv.key}`;
|
||||
// Drop any stale record of the same remote before taking the new one.
|
||||
for (const [k, q] of this.participants) if (q.ssrc === s.ssrc && k !== key) this.participants.delete(k);
|
||||
const p = {
|
||||
key, ssrc: s.ssrc, token: inv.token, name: inv.remoteName || s.name || inv.host, label: inv.label,
|
||||
address: rinfo.address, controlPort: inv.port, dataPort: rinfo.port,
|
||||
outbound: true, connected: true, lastSeq: -1, fedBack: -1,
|
||||
lastHeard: Date.now(), lastSyncAnswer: Date.now(), since: Date.now(), syncs: 0,
|
||||
};
|
||||
this.participants.set(key, p);
|
||||
inv.stage = 'connected';
|
||||
inv.error = '';
|
||||
this.log(`joined "${p.name}" at ${inv.host} (${rinfo.address}:${inv.port})`);
|
||||
this.emit('change');
|
||||
this.startSync(p, inv);
|
||||
}
|
||||
}
|
||||
|
||||
onRejected(s, rinfo) {
|
||||
const inv = this.inviteFor(rinfo, s.token);
|
||||
if (!inv) return;
|
||||
clearTimeout(inv.timer);
|
||||
inv.stage = 'idle';
|
||||
inv.error = 'the session refused us — set "Who may connect" to Anyone in Audio MIDI Setup';
|
||||
this.log(`${inv.host}: invitation refused`);
|
||||
this.emit('change');
|
||||
this.later(REINVITE_MS * 2, () => this.beginInvite(inv));
|
||||
}
|
||||
|
||||
onBye(s, rinfo) {
|
||||
for (const [k, p] of this.participants) {
|
||||
if (p.ssrc === s.ssrc && p.address === rinfo.address) this.drop(k, 'left the session');
|
||||
}
|
||||
}
|
||||
|
||||
drop(key, why) {
|
||||
const p = this.participants.get(key);
|
||||
if (!p) return;
|
||||
this.participants.delete(key);
|
||||
clearInterval(p.syncTimer);
|
||||
this.timers.delete(p.syncTimer);
|
||||
this.log(`"${p.name}" ${why}`);
|
||||
// If we were asked to keep this one, go and get it back.
|
||||
for (const inv of this.invites.values()) {
|
||||
if (inv.address === p.address && inv.port === p.controlPort && inv.stage === 'connected') {
|
||||
inv.stage = 'idle';
|
||||
inv.error = why;
|
||||
this.later(p.outbound ? 1000 : REINVITE_MS, () => this.beginInvite(inv));
|
||||
}
|
||||
}
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
/* — clock sync — */
|
||||
|
||||
startSync(p) {
|
||||
const tick = () => {
|
||||
if (!this.participants.has(p.key)) return;
|
||||
if (Date.now() - p.lastSyncAnswer > SYNC_TIMEOUT_MS) {
|
||||
this.send(this.control, sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address);
|
||||
this.drop(p.key, 'stopped answering');
|
||||
return;
|
||||
}
|
||||
this.send(this.data, syncPacket(this.ssrc, 0, [this.now(), 0n, 0n]), p.dataPort, p.address);
|
||||
p.syncs++;
|
||||
if (p.syncs === 6) {
|
||||
clearInterval(p.syncTimer);
|
||||
this.timers.delete(p.syncTimer);
|
||||
p.syncTimer = this.every(SYNC_MS, tick);
|
||||
}
|
||||
};
|
||||
p.syncTimer = this.every(SYNC_FAST_MS, tick);
|
||||
tick();
|
||||
}
|
||||
|
||||
onSync(s, rinfo) {
|
||||
const p = [...this.participants.values()].find((q) => q.ssrc === s.ssrc);
|
||||
if (p) p.lastHeard = Date.now();
|
||||
if (s.count === 0) {
|
||||
this.send(this.data, syncPacket(this.ssrc, 1, [s.ts[0], this.now(), 0n]), rinfo.port, rinfo.address);
|
||||
} else if (s.count === 1) {
|
||||
if (p) p.lastSyncAnswer = Date.now();
|
||||
this.send(this.data, syncPacket(this.ssrc, 2, [s.ts[0], s.ts[1], this.now()]), rinfo.port, rinfo.address);
|
||||
}
|
||||
// count 2 completes a sync someone else started; nothing to answer.
|
||||
}
|
||||
|
||||
/** Drop inbound participants that have gone quiet (their machine slept or left without a BY). */
|
||||
reap() {
|
||||
const now = Date.now();
|
||||
for (const [k, p] of this.participants) {
|
||||
if (!p.outbound && now - (p.lastHeard || 0) > IDLE_MS) this.drop(k, 'went quiet');
|
||||
}
|
||||
}
|
||||
|
||||
sendFeedback() {
|
||||
for (const p of this.participants.values()) {
|
||||
if (!p.connected || p.lastSeq < 0 || p.lastSeq === p.fedBack) continue;
|
||||
p.fedBack = p.lastSeq;
|
||||
this.send(this.control, feedbackPacket(this.ssrc, p.lastSeq), p.controlPort, p.address);
|
||||
}
|
||||
}
|
||||
|
||||
/* — MIDI — */
|
||||
|
||||
onRtp(buf, rinfo) {
|
||||
const pkt = parseRtpMidi(buf);
|
||||
if (!pkt) return;
|
||||
let p = [...this.participants.values()].find((q) => q.ssrc === pkt.ssrc && q.connected);
|
||||
if (!p) {
|
||||
// MIDI from someone not in a session with us (a stale session after our restart).
|
||||
// Ask them to start over so the session comes back properly — once in a while, not per note.
|
||||
const last = this.strangers.get(rinfo.address) || 0;
|
||||
if (Date.now() - last > 5000) {
|
||||
this.strangers.set(rinfo.address, Date.now());
|
||||
this.send(this.control, sessionPacket(CMD.BY, 0, this.ssrc), rinfo.port - 1, rinfo.address);
|
||||
}
|
||||
return;
|
||||
}
|
||||
p.lastHeard = Date.now();
|
||||
p.lastSeq = pkt.seq;
|
||||
for (const m of pkt.messages) {
|
||||
const event = describe(m);
|
||||
if (event) this.emit('midi', { participant: p, event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Session,
|
||||
parseRtpMidi,
|
||||
parseSession,
|
||||
describe,
|
||||
sessionPacket,
|
||||
syncPacket,
|
||||
CMD,
|
||||
};
|
||||
@@ -1,428 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Vista Cues — server part.
|
||||
*
|
||||
* Vista 3 cannot say what it is playing: its OSC is input-only and it sends
|
||||
* no feedback. What it *does* have is a network MIDI session that
|
||||
* ProPresenter and the tracks rig fire cue lists through. A session sends
|
||||
* every message to every participant, so this module joins those sessions
|
||||
* as one more participant (rtpmidi.js) and hears exactly what Vista hears.
|
||||
* The admin's "Cue lists" sheet (mapping.js) turns each note into
|
||||
* "Go on Walk-in", "Release Worship 1"…, and from those the module keeps
|
||||
* which lists are live.
|
||||
*
|
||||
* GET /state → { state } (instant paint)
|
||||
* GET /stream → SSE: `state` on every change
|
||||
* POST /clear → forget which lists are live (when Vista was reset by hand)
|
||||
*
|
||||
* What the tile shows is what Vista was told, not what Vista confirmed: if
|
||||
* Vista is closed or someone works the desk by hand, the two can differ.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const { Session } = require('./rtpmidi');
|
||||
const { parseMapping, match, triggerLabel } = require('./mapping');
|
||||
|
||||
const HEARTBEAT_MS = 15000;
|
||||
const LOG_MAX = 40;
|
||||
const DNS_SD = '/usr/bin/dns-sd';
|
||||
|
||||
let current = null;
|
||||
|
||||
function emptyState(extra = {}) {
|
||||
return {
|
||||
listening: false,
|
||||
error: '',
|
||||
sessionName: '',
|
||||
port: null,
|
||||
advertised: false,
|
||||
sources: [],
|
||||
current: null,
|
||||
active: [],
|
||||
log: [],
|
||||
mappingErrors: [],
|
||||
rules: 0,
|
||||
now: Date.now(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
|
||||
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
|
||||
* session once per network, so a sender typed or picked from the wrong copy
|
||||
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
|
||||
* throws on a typo so the admin page says so at once.
|
||||
*/
|
||||
function parseNets(text) {
|
||||
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
|
||||
const parts = m ? m.slice(1, 5).map(Number) : [];
|
||||
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
|
||||
if (!m || parts.some((x) => x > 255) || bits > 32) {
|
||||
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
|
||||
}
|
||||
return { base: ipNumber(parts.join('.')), bits, text: n };
|
||||
});
|
||||
}
|
||||
|
||||
function ipNumber(a) {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
|
||||
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an address is not on the wired network: '' when it is (or there is no
|
||||
* rule), 'by name' for a host name (it resolves to whichever network answers
|
||||
* first), 'not wired' for an address outside every wired network.
|
||||
*/
|
||||
function offNetwork(nets, address) {
|
||||
if (!nets.length || !address) return '';
|
||||
const v = ipNumber(address);
|
||||
if (v === null) return 'by name';
|
||||
if (address.startsWith('127.')) return '';
|
||||
const span = (bits) => 2 ** (32 - bits);
|
||||
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
|
||||
}
|
||||
|
||||
function endpointOf(v) {
|
||||
const ep = v && typeof v === 'object' ? v : {};
|
||||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const sessionName = String(config.sessionName || '').trim() || 'ProdDash Lighting';
|
||||
const port = Math.round(Number(config.sessionPort)) || 5040;
|
||||
const middleC = config.middleC === 'C4' ? 'C4' : 'C3';
|
||||
const exclusive = config.exclusive === true;
|
||||
const headTakesOver = config.headTakesOver !== false;
|
||||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||||
const nets = parseNets(config.wiredNetwork);
|
||||
const netText = nets.map((n) => n.text).join(', ');
|
||||
if (mappingErrors.length) {
|
||||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||||
}
|
||||
|
||||
const senders = [
|
||||
{ label: String(config.source1Name || '').trim() || 'ProPresenter', ...endpointOf(config.source1) },
|
||||
{ label: String(config.source2Name || '').trim() || 'Tracks', ...endpointOf(config.source2) },
|
||||
{ label: String(config.source3Name || '').trim() || 'Companion', ...endpointOf(config.source3) },
|
||||
].filter((s) => s.host);
|
||||
|
||||
const streams = new Set();
|
||||
const lists = new Map(); // lower-cased name → { name, group, state, firedAt, lastAt, lastAction, source, trigger, goes }
|
||||
const history = []; // newest first
|
||||
let currentKey = '';
|
||||
let lastFrame = '';
|
||||
let advertiser = null;
|
||||
let advertised = false;
|
||||
let stopped = false;
|
||||
|
||||
if (port < 1024 || port > 65534) throw new Error('Session port must be between 1024 and 65534');
|
||||
|
||||
const session = new Session({ name: sessionName, port, log });
|
||||
const invites = senders.map((s) => ({ ...s, inv: null }));
|
||||
|
||||
/* ── what Vista was told ─────────────────────────────────────── */
|
||||
|
||||
function sourceLabel(p) {
|
||||
return p.label || p.name || p.address;
|
||||
}
|
||||
|
||||
function record(entry) {
|
||||
history.unshift({ at: Date.now(), ...entry });
|
||||
if (history.length > LOG_MAX) history.length = LOG_MAX;
|
||||
}
|
||||
|
||||
function apply(rule, source, trigger) {
|
||||
const now = Date.now();
|
||||
const key = rule.list.toLowerCase();
|
||||
record({ action: rule.action, list: rule.list, group: rule.group, source, trigger, mapped: true });
|
||||
if (rule.action === 'flash') return; // a momentary hit: in the log, never live
|
||||
if (rule.action === 'releaseAll') {
|
||||
lists.clear();
|
||||
currentKey = '';
|
||||
return;
|
||||
}
|
||||
if (rule.action === 'release') {
|
||||
lists.delete(key);
|
||||
if (currentKey === key) currentKey = newestKey();
|
||||
return;
|
||||
}
|
||||
let l = lists.get(key);
|
||||
if (rule.action === 'go') {
|
||||
// A Go replaces the other lists of its group. A Go in the first group (the
|
||||
// main scene) takes over everything, as Vista's last-takes-precedence does;
|
||||
// and with "Go releases others" on, every Go does.
|
||||
const takeover = exclusive || (headTakesOver && rule.group && rule.group === groups[0]);
|
||||
for (const [k, other] of [...lists]) {
|
||||
if (k !== key && (takeover || (rule.group && other.group === rule.group))) lists.delete(k);
|
||||
}
|
||||
if (!l) {
|
||||
l = { name: rule.list, group: rule.group, state: 'active', firedAt: now, goes: 0 };
|
||||
lists.set(key, l);
|
||||
}
|
||||
l.state = 'active';
|
||||
l.goes++;
|
||||
l.lastAt = now;
|
||||
l.lastAction = 'go';
|
||||
l.source = source;
|
||||
l.trigger = trigger;
|
||||
currentKey = key;
|
||||
return;
|
||||
}
|
||||
// pause / back act on a list that is live; on one that isn't, they only go in the log.
|
||||
if (!l) return;
|
||||
l.lastAt = now;
|
||||
l.lastAction = rule.action;
|
||||
l.source = source;
|
||||
l.trigger = trigger;
|
||||
if (rule.action === 'pause') l.state = 'paused'; // the next Go resumes it
|
||||
if (rule.action === 'back') { l.state = 'active'; l.goes = Math.max(1, l.goes - 1); }
|
||||
}
|
||||
|
||||
function newestKey() {
|
||||
let best = '';
|
||||
let at = -1;
|
||||
for (const [k, l] of lists) if (l.lastAt > at) { best = k; at = l.lastAt; }
|
||||
return best;
|
||||
}
|
||||
|
||||
session.on('midi', ({ participant, event }) => {
|
||||
if (event.type !== 'noteon' && event.type !== 'program') return;
|
||||
const source = sourceLabel(participant);
|
||||
const trigger = triggerLabel(event, middleC);
|
||||
const rule = match(rules, event);
|
||||
if (rule) {
|
||||
apply(rule, source, trigger);
|
||||
log(`${source}: ${trigger} → ${rule.action}${rule.list ? ` ${rule.list}` : ''}`);
|
||||
} else {
|
||||
record({ action: '', list: '', source, trigger, mapped: false });
|
||||
}
|
||||
broadcast();
|
||||
});
|
||||
session.on('change', () => broadcast());
|
||||
// Senders are joined once our ports are ours — straight away normally,
|
||||
// or a moment later when a busy port frees up.
|
||||
session.on('listening', () => {
|
||||
if (stopped) return;
|
||||
log(`session "${sessionName}" listening on UDP ${port}/${port + 1}`);
|
||||
for (const s of invites) s.inv = session.invite(s.host, s.port, s.label);
|
||||
if (!advertiser) advertise();
|
||||
});
|
||||
|
||||
/* ── the view-model ──────────────────────────────────────────── */
|
||||
|
||||
function sources() {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const s of invites) {
|
||||
const inv = s.inv;
|
||||
const p = inv && [...session.participants.values()].find((q) => q.connected && q.address === inv.address && q.controlPort === inv.port);
|
||||
if (p) seen.add(p.key);
|
||||
out.push({
|
||||
label: s.label,
|
||||
name: p ? p.name : '',
|
||||
address: inv?.address || s.host,
|
||||
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
|
||||
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
|
||||
host: s.host,
|
||||
configured: true,
|
||||
connected: Boolean(p),
|
||||
error: p ? '' : (inv?.error || ''),
|
||||
lastHeard: p ? p.lastHeard : null,
|
||||
});
|
||||
}
|
||||
for (const p of session.participants.values()) {
|
||||
if (seen.has(p.key) || !p.connected) continue;
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function groupRank(l) {
|
||||
const i = groups.indexOf(l.group);
|
||||
return i < 0 ? groups.length : i;
|
||||
}
|
||||
|
||||
function buildState() {
|
||||
const live = [...lists.values()].sort((a, b) => b.lastAt - a.lastAt);
|
||||
// The headline: the live list of the sheet's first group (the scene, say,
|
||||
// rather than whichever colour changed last); without groups, the last Go.
|
||||
const head = groups[0];
|
||||
const cur = (head && live.find((l) => l.group === head))
|
||||
|| (currentKey ? lists.get(currentKey) : null)
|
||||
|| null;
|
||||
return {
|
||||
listening: Boolean(session.control),
|
||||
error: session.error,
|
||||
sessionName,
|
||||
port,
|
||||
advertised,
|
||||
wiredNetwork: netText,
|
||||
sources: sources(),
|
||||
current: cur || null,
|
||||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||||
active: live.filter((l) => l !== cur).sort((a, b) => groupRank(a) - groupRank(b) || b.lastAt - a.lastAt),
|
||||
headline: head || '',
|
||||
log: history,
|
||||
mappingErrors,
|
||||
rules: rules.length,
|
||||
now: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function send(payload) {
|
||||
for (const res of streams) {
|
||||
try { res.write(payload); } catch { streams.delete(res); }
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
if (stopped) return;
|
||||
const state = buildState();
|
||||
const { now, sources: src, ...rest } = state;
|
||||
// lastHeard moves with every packet; leave it out of change detection.
|
||||
const frame = JSON.stringify({ ...rest, src: src.map(({ lastHeard, ...s }) => s) });
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
send(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||||
}
|
||||
|
||||
const keepalive = setInterval(() => send(': ping\n\n'), HEARTBEAT_MS);
|
||||
keepalive.unref?.();
|
||||
|
||||
/* ── being found: Bonjour on macOS ───────────────────────────── */
|
||||
|
||||
// Advertising _apple-midi._udp puts us in Audio MIDI Setup's Directory on
|
||||
// every Mac here, so a sender can add us with a click. macOS ships the
|
||||
// dns-sd tool; we run it as a child and stop it with the module. Elsewhere
|
||||
// nothing is advertised and senders are joined by address alone.
|
||||
function advertise() {
|
||||
if (stopped || config.advertise === false || process.platform !== 'darwin' || !fs.existsSync(DNS_SD)) return;
|
||||
advertiser = spawn(DNS_SD, ['-R', sessionName, '_apple-midi._udp', 'local', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
advertiser.stdout.on('data', (d) => {
|
||||
if (/Name now registered and active/i.test(String(d)) && !advertised) {
|
||||
advertised = true;
|
||||
log(`advertised as "${sessionName}" on port ${port}`);
|
||||
broadcast();
|
||||
}
|
||||
});
|
||||
advertiser.stderr.on('data', () => {});
|
||||
advertiser.on('error', (e) => { log(`dns-sd: ${e.message}`); advertiser = null; });
|
||||
advertiser.on('exit', () => {
|
||||
advertiser = null;
|
||||
advertised = false;
|
||||
if (!stopped) setTimeout(advertise, 5000).unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── start ───────────────────────────────────────────────────── */
|
||||
|
||||
session.start().then(() => broadcast());
|
||||
|
||||
current = {
|
||||
streams,
|
||||
getState: buildState,
|
||||
clear() {
|
||||
lists.clear();
|
||||
currentKey = '';
|
||||
record({ action: 'cleared', list: '', source: 'Dashboard', trigger: '', mapped: true });
|
||||
broadcast();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(keepalive);
|
||||
session.stop();
|
||||
if (advertiser) { try { advertiser.kill(); } catch { /* gone */ } advertiser = null; }
|
||||
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (session.error) return { status: 'error', message: session.error };
|
||||
const src = sources();
|
||||
// Off the wired network: still joined (the cues keep working), but said first and in red.
|
||||
const off = src.filter((s) => s.offNetwork);
|
||||
if (off.length) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${off.map((s) => (s.offNetwork === 'by name'
|
||||
? `${s.label} is set by name (${s.host})`
|
||||
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
|
||||
};
|
||||
}
|
||||
const on = src.filter((s) => s.connected);
|
||||
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||||
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||||
if (on.length) {
|
||||
const off = src.filter((s) => !s.connected).map((s) => `${s.label} (${s.error || 'joining…'})`);
|
||||
return {
|
||||
status: off.length ? 'connecting' : 'ok',
|
||||
message: `In session with ${on.map((s) => s.label).join(', ')}${off.length ? ` · waiting for ${off.join(', ')}` : ''}${map}${lines}`,
|
||||
};
|
||||
}
|
||||
const where = advertised ? `advertised as "${sessionName}"` : `port ${port}`;
|
||||
if (src.length) {
|
||||
return { status: 'connecting', message: `Joining ${src.map((s) => `${s.label}${s.error ? ` (${s.error})` : ''}`).join(', ')} · ${where}${map}${lines}` };
|
||||
}
|
||||
return { status: 'connecting', message: `Waiting for a MIDI session to connect · ${where}${map}${lines}` };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// For the tests.
|
||||
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
|
||||
|
||||
/**
|
||||
* One picker entry, so the + menu gives Vista Cues a heading of its own —
|
||||
* a module with no list sits headless under whichever module precedes it
|
||||
* (Slack, alphabetically). Tiles added before this keep working: they
|
||||
* have no variant and use the module's schema.
|
||||
*/
|
||||
tiles() {
|
||||
return [{
|
||||
id: 'live',
|
||||
name: 'Live cue lists',
|
||||
description: 'The main scene Vista is running, the FX on top, and what fired them',
|
||||
defaultSize: { w: 5, h: 4 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
}];
|
||||
},
|
||||
|
||||
routes() {
|
||||
const sendJson = (res, code, body) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
return {
|
||||
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getState() : emptyState())}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
const set = current.streams;
|
||||
set.add(res);
|
||||
req.on('close', () => set.delete(res));
|
||||
},
|
||||
'POST /clear': (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Vista Cues is not running' });
|
||||
current.clear();
|
||||
return sendJson(res, 200, { ok: true });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,187 +0,0 @@
|
||||
/* Vista Cues. Sizes hang off the tile (container units) times the tile's
|
||||
own text scale (--vc-scale, the title bar's − / +), so one tile reads the
|
||||
same at 3×2 on a laptop as at 8×5 on a wall. Colours are the shell's. */
|
||||
|
||||
[data-module="vista-cues"] .vc-root {
|
||||
--tone: var(--text);
|
||||
--vc-scale: 1;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
container-type: size;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root[data-tone="ok"] { --tone: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-root[data-tone="warn"] { --tone: var(--warn); }
|
||||
[data-module="vista-cues"] .vc-root[data-tone="muted"] { --tone: var(--muted); }
|
||||
[data-module="vista-cues"] [hidden] { display: none !important; }
|
||||
|
||||
[data-module="vista-cues"] .vc-body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(clamp(3px, 2.5cqh, 14px) * var(--vc-scale));
|
||||
padding: clamp(6px, 3.5cqmin, 22px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-module="vista-cues"] .vc-offline {
|
||||
position: absolute;
|
||||
top: clamp(4px, 3cqmin, 14px);
|
||||
right: clamp(6px, 3.5cqmin, 22px);
|
||||
z-index: 2;
|
||||
font-size: calc(clamp(9px, 4.5cqmin, 14px) * var(--vc-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root.is-offline .vc-now { opacity: 0.45; filter: grayscale(0.6); }
|
||||
|
||||
/* ── now ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-now {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
padding-left: clamp(6px, 2cqmin, 14px);
|
||||
border-left: clamp(3px, 1.1cqmin, 7px) solid var(--tone);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
font-size: calc(clamp(10px, 5cqmin, 18px) * var(--vc-scale));
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-pill {
|
||||
padding: 0.05em 0.55em;
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--tone);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root[data-tone="warn"] .vc-pill { color: var(--bg); }
|
||||
[data-module="vista-cues"] .vc-ago { font-weight: 600; }
|
||||
[data-module="vista-cues"] .vc-name-wrap { min-width: 0; }
|
||||
[data-module="vista-cues"] .vc-name {
|
||||
font-size: calc(clamp(16px, 14cqmin, 96px) * var(--vc-scale));
|
||||
line-height: 1.12;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-root[data-tone="muted"] .vc-name { color: var(--muted); font-weight: 600; }
|
||||
[data-module="vista-cues"] .vc-meta {
|
||||
font-size: calc(clamp(10px, 4.6cqmin, 18px) * var(--vc-scale));
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-meta:empty { display: none; }
|
||||
|
||||
/* ── other live lists ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-active {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
flex: 0 0 auto;
|
||||
font-size: calc(clamp(10px, 4.4cqmin, 17px) * var(--vc-scale));
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
max-width: 100%;
|
||||
padding: 0.2em 0.65em;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip.is-paused {
|
||||
border-color: color-mix(in srgb, var(--warn) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--warn) 14%, transparent);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip-name {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-chip-ago,
|
||||
[data-module="vista-cues"] .vc-chip-group { color: var(--muted); }
|
||||
[data-module="vista-cues"] .vc-chip-group { font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* ── recent ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-recent {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
font-size: calc(clamp(10px, 3.8cqmin, 15px) * var(--vc-scale));
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.35em;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 6.2em minmax(0, 1fr) auto;
|
||||
gap: 0.8em;
|
||||
align-items: baseline;
|
||||
padding: 0.12em 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="vista-cues"] .vc-row > span { overflow: hidden; text-overflow: ellipsis; }
|
||||
[data-module="vista-cues"] .vc-row-time,
|
||||
[data-module="vista-cues"] .vc-row-source { color: var(--muted); }
|
||||
[data-module="vista-cues"] .vc-row-action { font-weight: 700; color: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-row[data-action="release"] .vc-row-action,
|
||||
[data-module="vista-cues"] .vc-row[data-action="releaseAll"] .vc-row-action,
|
||||
[data-module="vista-cues"] .vc-row[data-action="cleared"] .vc-row-action { color: var(--muted); }
|
||||
[data-module="vista-cues"] .vc-row[data-action="pause"] .vc-row-action { color: var(--warn); }
|
||||
[data-module="vista-cues"] .vc-row[data-action="flash"] .vc-row-action { color: var(--text); }
|
||||
[data-module="vista-cues"] .vc-row.is-unmapped { opacity: 0.6; }
|
||||
[data-module="vista-cues"] .vc-row.is-unmapped .vc-row-action { color: var(--muted); font-weight: 500; }
|
||||
|
||||
@container (max-width: 300px) {
|
||||
[data-module="vista-cues"] .vc-row { grid-template-columns: 5.2em minmax(0, 1fr); }
|
||||
[data-module="vista-cues"] .vc-row-time,
|
||||
[data-module="vista-cues"] .vc-row-source { display: none; }
|
||||
}
|
||||
@container (max-height: 140px) {
|
||||
[data-module="vista-cues"] .vc-recent,
|
||||
[data-module="vista-cues"] .vc-active { display: none; }
|
||||
}
|
||||
|
||||
/* ── senders ── */
|
||||
|
||||
[data-module="vista-cues"] .vc-sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9em;
|
||||
margin-top: auto;
|
||||
font-size: calc(clamp(9px, 3.4cqmin, 13px) * var(--vc-scale));
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-src { display: inline-flex; align-items: center; gap: 0.4em; }
|
||||
[data-module="vista-cues"] .vc-dot {
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
}
|
||||
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
|
||||
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
|
||||
@container (max-height: 140px) {
|
||||
[data-module="vista-cues"] .vc-sources { display: none; }
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for Vista Cues.
|
||||
*
|
||||
* node modules/vista-cues/vista.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. Covers
|
||||
* the cue-sheet parser, RTP-MIDI packet decoding, and a real session over
|
||||
* loopback UDP: a fake sender invites the module's session, plays notes, and
|
||||
* the server entry's state follows.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const dgram = require('dgram');
|
||||
const http = require('http');
|
||||
const { parseMapping, match, noteFromName } = require('./mapping');
|
||||
const { parseRtpMidi, describe, Session } = require('./rtpmidi');
|
||||
|
||||
let passed = 0;
|
||||
async function test(name, fn) {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function until(fn, ms = 3000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (fn()) return;
|
||||
await wait(20);
|
||||
}
|
||||
throw new Error('timed out waiting');
|
||||
}
|
||||
|
||||
/* ── a minimal independent sender ─────────────────────────────────────── */
|
||||
|
||||
function sessionPkt(cmd, token, ssrc, name) {
|
||||
const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`);
|
||||
const b = Buffer.alloc(16 + n.length);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write(cmd, 2, 'ascii');
|
||||
b.writeUInt32BE(2, 4);
|
||||
b.writeUInt32BE(token, 8);
|
||||
b.writeUInt32BE(ssrc, 12);
|
||||
n.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
function rtp(ssrc, seq, midiBytes, flags = 0) {
|
||||
const midi = Buffer.from(midiBytes);
|
||||
const b = Buffer.alloc(13 + midi.length);
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x61;
|
||||
b.writeUInt16BE(seq, 2);
|
||||
b.writeUInt32BE(1234, 4);
|
||||
b.writeUInt32BE(ssrc, 8);
|
||||
b[12] = flags | midi.length;
|
||||
midi.copy(b, 13);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** A sender that accepts invitations (like a Mac session) and can play notes. */
|
||||
async function fakeSender(port, name) {
|
||||
const ssrc = 0x0badf00d;
|
||||
const control = dgram.createSocket('udp4');
|
||||
const data = dgram.createSocket('udp4');
|
||||
const peers = [];
|
||||
const syncs = [];
|
||||
let seq = 100;
|
||||
const handle = (sock, which) => (buf, r) => {
|
||||
const cmd = buf.toString('ascii', 2, 4);
|
||||
if (cmd === 'IN') {
|
||||
sock.send(sessionPkt('OK', buf.readUInt32BE(8), ssrc, name), r.port, r.address);
|
||||
if (which === 'data') peers.push({ address: r.address, port: r.port });
|
||||
} else if (cmd === 'CK') {
|
||||
syncs.push(buf[8]);
|
||||
if (buf[8] === 0) {
|
||||
const out = Buffer.from(buf);
|
||||
out.writeUInt32BE(ssrc, 4);
|
||||
out[8] = 1;
|
||||
data.send(out, r.port, r.address);
|
||||
}
|
||||
}
|
||||
};
|
||||
control.on('message', handle(control, 'control'));
|
||||
data.on('message', handle(data, 'data'));
|
||||
await new Promise((r) => control.bind(port, '127.0.0.1', r));
|
||||
await new Promise((r) => data.bind(port + 1, '127.0.0.1', r));
|
||||
return {
|
||||
peers,
|
||||
syncs,
|
||||
note(n, ch = 1) {
|
||||
for (const p of peers) data.send(rtp(ssrc, seq++, [0x90 | (ch - 1), n, 100, 0x00, n, 0]), p.port, p.address);
|
||||
},
|
||||
close() { control.close(); data.close(); },
|
||||
};
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve) => {
|
||||
const s = dgram.createSocket('udp4');
|
||||
s.bind(0, () => { const p = s.address().port; s.close(() => resolve(p)); });
|
||||
});
|
||||
}
|
||||
|
||||
/** Two adjacent free UDP ports, as a session needs. */
|
||||
async function freePair() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const p = await freePort();
|
||||
if (p > 65000) continue;
|
||||
const ok = await new Promise((resolve) => {
|
||||
const s = dgram.createSocket('udp4');
|
||||
s.on('error', () => resolve(false));
|
||||
s.bind(p + 1, () => s.close(() => resolve(true)));
|
||||
});
|
||||
if (ok) return p;
|
||||
}
|
||||
throw new Error('no free port pair');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('mapping');
|
||||
|
||||
await test('reads the cue sheet forms', () => {
|
||||
const { rules, errors } = parseMapping([
|
||||
'# comment',
|
||||
'60 = Walk-in',
|
||||
'C3 - Worship 1',
|
||||
'61 release Walk-in',
|
||||
'62\tPause\tWorship 1',
|
||||
'127 release all',
|
||||
'ch2:64 = Message # only on channel 2',
|
||||
'pc5 = Baptism',
|
||||
'note 66: Go: Song #2',
|
||||
'67 = "Back Lights"',
|
||||
'F#2 = Hazer',
|
||||
].join('\n'));
|
||||
assert.deepStrictEqual(errors, []);
|
||||
const brief = rules.map((r) => [r.channel, r.kind, r.number, r.action, r.list]);
|
||||
assert.deepStrictEqual(brief, [
|
||||
[null, 'note', 60, 'go', 'Walk-in'],
|
||||
[null, 'note', 60, 'go', 'Worship 1'],
|
||||
[null, 'note', 61, 'release', 'Walk-in'],
|
||||
[null, 'note', 62, 'pause', 'Worship 1'],
|
||||
[null, 'note', 127, 'releaseAll', ''],
|
||||
[2, 'note', 64, 'go', 'Message'],
|
||||
[null, 'program', 5, 'go', 'Baptism'],
|
||||
[null, 'note', 66, 'go', 'Song #2'],
|
||||
[null, 'note', 67, 'go', 'Back Lights'],
|
||||
[null, 'note', 54, 'go', 'Hazer'],
|
||||
]);
|
||||
});
|
||||
|
||||
await test('middle C naming', () => {
|
||||
assert.strictEqual(noteFromName('C3', 'C3'), 60);
|
||||
assert.strictEqual(noteFromName('C4', 'C4'), 60);
|
||||
assert.strictEqual(noteFromName('C-2', 'C3'), 0);
|
||||
assert.strictEqual(noteFromName('Bb3', 'C3'), 70);
|
||||
});
|
||||
|
||||
await test('bad lines are reported, not fatal', () => {
|
||||
const { rules, errors } = parseMapping('hello\n200 = Big\n61 release\nch17:3 = X\n60 = OK');
|
||||
assert.strictEqual(rules.length, 1);
|
||||
assert.deepStrictEqual(errors.map((e) => e.line), [1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
await test('groups and flashes', () => {
|
||||
const { rules, groups, errors } = parseMapping('5 = Loose\n[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n23 = Blue\n30 flash Blinder Flash 1\n[]\n9 = After');
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.deepStrictEqual(groups, ['Scene', 'Color']);
|
||||
assert.deepStrictEqual(rules.map((r) => [r.number, r.group, r.action]), [
|
||||
[5, '', 'go'], [0, 'Scene', 'go'], [8, 'Scene', 'go'], [23, 'Color', 'go'], [30, 'Color', 'flash'], [9, '', 'go'],
|
||||
]);
|
||||
});
|
||||
|
||||
await test('the NA cue sheet parses cleanly', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const file = path.join(__dirname, '..', '..', '..', 'NA cue sheet.txt');
|
||||
if (!fs.existsSync(file)) return;
|
||||
const { rules, errors, groups } = parseMapping(fs.readFileSync(file, 'utf8'));
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.strictEqual(rules.length, 53); // every note marked TRUE for NA in the sheet
|
||||
assert.deepStrictEqual(groups, ['Main', 'Color', 'Blinders', 'Build', 'House', 'Lekos']);
|
||||
assert.strictEqual(new Set(rules.map((r) => r.number)).size, rules.length);
|
||||
});
|
||||
|
||||
await test('matching respects channel and kind', () => {
|
||||
const { rules } = parseMapping('ch2:64 = Two\n64 = Any\npc64 = Program');
|
||||
assert.strictEqual(match(rules, { type: 'noteon', channel: 2, note: 64 }).list, 'Two');
|
||||
assert.strictEqual(match(rules, { type: 'noteon', channel: 1, note: 64 }).list, 'Any');
|
||||
assert.strictEqual(match(rules, { type: 'program', channel: 1, program: 64 }).list, 'Program');
|
||||
assert.strictEqual(match(rules, { type: 'noteoff', channel: 1, note: 64 }), null);
|
||||
});
|
||||
|
||||
console.log('rtp-midi');
|
||||
|
||||
await test('note on + running-status note off', () => {
|
||||
const p = parseRtpMidi(rtp(7, 9, [0x90, 60, 100, 0x00, 60, 0]));
|
||||
assert.strictEqual(p.ssrc, 7);
|
||||
assert.strictEqual(p.seq, 9);
|
||||
assert.deepStrictEqual(p.messages.map(describe), [
|
||||
{ type: 'noteon', channel: 1, note: 60, velocity: 100 },
|
||||
{ type: 'noteoff', channel: 1, note: 60 },
|
||||
]);
|
||||
});
|
||||
|
||||
await test('Z flag, multi-byte deltas, program change, sysex in between', () => {
|
||||
const p = parseRtpMidi(rtp(7, 1, [0x05, 0xc2, 4, 0x81, 0x00, 0xf0, 0x7f, 0x01, 0xf7, 0x00, 0x93, 61, 9], 0x20));
|
||||
assert.deepStrictEqual(p.messages.map(describe).filter(Boolean), [
|
||||
{ type: 'program', channel: 3, program: 4 },
|
||||
{ type: 'noteon', channel: 4, note: 61, velocity: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
await test('journal bytes after the command section are ignored', () => {
|
||||
const b = Buffer.concat([rtp(7, 1, [0x90, 62, 1], 0x40), Buffer.from([0x80, 0x00, 0x05, 0x01, 0x02])]);
|
||||
assert.deepStrictEqual(parseRtpMidi(b).messages.map(describe), [{ type: 'noteon', channel: 1, note: 62, velocity: 1 }]);
|
||||
});
|
||||
|
||||
console.log('session');
|
||||
|
||||
await test('invites a sender, keeps sync, and hears its notes', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake PP');
|
||||
const port = await freePair();
|
||||
const s = new Session({ name: 'Test', port });
|
||||
assert.ok(await s.start());
|
||||
const heard = [];
|
||||
s.on('midi', ({ participant, event }) => heard.push([participant.label, event.type, event.note]));
|
||||
s.invite('127.0.0.1', senderPort, 'ProPresenter');
|
||||
await until(() => s.list().some((p) => p.connected));
|
||||
await until(() => sender.syncs.includes(2)); // we answered its count-1 with a count-2
|
||||
sender.note(60);
|
||||
await until(() => heard.length >= 2);
|
||||
assert.deepStrictEqual(heard, [['ProPresenter', 'noteon', 60], ['ProPresenter', 'noteoff', 60]]);
|
||||
s.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('a config save re-inits on the same port straight away', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake PP');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
const config = { sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort } };
|
||||
let handle = mod.init({ config, log: () => {} });
|
||||
await until(() => handle.health().status === 'ok');
|
||||
handle.stop();
|
||||
handle = mod.init({ config, log: () => {} }); // what the shell does on save: stop(), then init()
|
||||
await until(() => handle.health().status === 'ok');
|
||||
handle.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('a port taken by someone else is reported, then taken once free', async () => {
|
||||
const port = await freePair();
|
||||
const squatter = dgram.createSocket('udp4');
|
||||
await new Promise((r) => squatter.bind(port + 1, r));
|
||||
const s = new Session({ name: 'Test', port });
|
||||
let listening = false;
|
||||
s.on('listening', () => { listening = true; });
|
||||
assert.strictEqual(await s.start(), false);
|
||||
assert.match(s.error, /in use/);
|
||||
squatter.close();
|
||||
await until(() => listening, 5000);
|
||||
assert.strictEqual(s.error, '');
|
||||
s.stop();
|
||||
});
|
||||
|
||||
await test('server entry: notes become live cue lists, over HTTP', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake PP');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
const handle = mod.init({
|
||||
config: {
|
||||
sessionPort: port,
|
||||
advertise: false,
|
||||
source1Name: 'ProPresenter',
|
||||
source1: { host: '127.0.0.1', port: senderPort },
|
||||
cueLists: '60 = Walk-in\n61 = Worship 1\n70 release Walk-in\n127 release all',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
const routes = mod.routes({});
|
||||
const server = http.createServer((req, res) => {
|
||||
const r = routes[`${req.method} ${req.url}`];
|
||||
if (r) r(req, res); else { res.writeHead(404); res.end(); }
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const get = async (path, method = 'GET') => {
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { method });
|
||||
return res.json();
|
||||
};
|
||||
const state = async () => (await get('/state')).state;
|
||||
|
||||
await until(() => sender.peers.length > 0);
|
||||
await until(() => handle.health().status === 'ok');
|
||||
sender.note(60);
|
||||
await wait(100);
|
||||
sender.note(61);
|
||||
await wait(100);
|
||||
let st = await state();
|
||||
assert.strictEqual(st.current.name, 'Worship 1');
|
||||
assert.deepStrictEqual(st.active.map((l) => l.name), ['Walk-in']);
|
||||
assert.strictEqual(st.current.source, 'ProPresenter');
|
||||
assert.strictEqual(st.sources[0].connected, true);
|
||||
|
||||
sender.note(70);
|
||||
sender.note(99); // unmapped
|
||||
await wait(150);
|
||||
st = await state();
|
||||
assert.deepStrictEqual(st.active, []);
|
||||
assert.strictEqual(st.log[0].mapped, false);
|
||||
assert.match(st.log[0].trigger, /Note 99/);
|
||||
|
||||
sender.note(127);
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.strictEqual(st.current, null);
|
||||
|
||||
sender.note(60);
|
||||
await wait(100);
|
||||
await get('/clear', 'POST');
|
||||
st = await state();
|
||||
assert.strictEqual(st.current, null);
|
||||
assert.strictEqual(st.log[0].action, 'cleared');
|
||||
handle.stop();
|
||||
|
||||
// Groups: a Go replaces its group's live list; the first group is the headline; flashes stay out.
|
||||
const h2 = mod.init({
|
||||
config: {
|
||||
sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort },
|
||||
cueLists: '[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n22 = Red\n23 = Blue\n30 flash Blinder Flash 1',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
await until(() => h2.health().status === 'ok');
|
||||
for (const n of [0, 22, 23, 30]) { sender.note(n); await wait(60); }
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.strictEqual(st.current.name, 'Pre Service');
|
||||
assert.deepStrictEqual(st.active.map((l) => [l.group, l.name]), [['Color', 'Blue']]); // Blue replaced Red
|
||||
assert.strictEqual(st.log[0].action, 'flash');
|
||||
sender.note(8); // a new main scene takes over the look…
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.strictEqual(st.current.name, 'Message');
|
||||
assert.deepStrictEqual(st.active, []);
|
||||
sender.note(22); // …and FX fired after it layer on top again
|
||||
await wait(100);
|
||||
st = await state();
|
||||
assert.deepStrictEqual(st.active.map((l) => l.name), ['Red']);
|
||||
h2.stop();
|
||||
|
||||
server.close();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('wired network: addresses outside it, and names, are flagged', () => {
|
||||
const mod = require('./server');
|
||||
const off = mod._offNetwork;
|
||||
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
|
||||
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
|
||||
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
|
||||
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
|
||||
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
|
||||
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
|
||||
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
|
||||
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
|
||||
});
|
||||
|
||||
await test('server entry: a sender off the wired network still joins, and says so', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake Tracks');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
|
||||
const handle = mod.init({
|
||||
config: {
|
||||
sessionPort: port,
|
||||
advertise: false,
|
||||
wiredNetwork: '10.1.0.0/16',
|
||||
source2Name: 'Tracks',
|
||||
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
|
||||
cueLists: '60 = Walk-in',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
await until(() => sender.peers.length > 0); // joined all the same
|
||||
const h = handle.health();
|
||||
assert.strictEqual(h.status, 'error');
|
||||
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
|
||||
const routes = mod.routes({});
|
||||
let body = '';
|
||||
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
|
||||
const st = JSON.parse(body).state;
|
||||
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
|
||||
assert.strictEqual(st.sources[0].offNetwork, 'by name');
|
||||
handle.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.13.0",
|
||||
"version": "1.12.1",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
+4
-93
@@ -188,10 +188,6 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
|
||||
/* ── module registry ────────────────────────────────────────────────── */
|
||||
|
||||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||||
/* The installed modules that are not running — disabled, or needing a newer
|
||||
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
|
||||
to say why a tile one of them left in the layout is not drawn. */
|
||||
let inactiveModules = new Map();
|
||||
|
||||
/** fetch that gives up after `ms`. A request the browser has queued behind
|
||||
held connections would otherwise pend forever, and so would anything
|
||||
@@ -212,7 +208,6 @@ async function loadRegistry() {
|
||||
if (!res.ok) throw new Error('modules ' + res.status);
|
||||
const body = await res.json();
|
||||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||||
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -492,9 +487,7 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
|
||||
registry offers them again: a Service timer waits for PCO Plan, a
|
||||
transcript for its channel. Never the module's default in their place —
|
||||
a wall of "All timers" where five different timers were is worse than
|
||||
five gaps that fill themselves in. For the one that is not coming back
|
||||
(a module stopped offering that kind of tile) the Layouts menu lists them
|
||||
under Not shown, each with a ✕ — the only way to let its cells go. */
|
||||
five gaps that fill themselves in. */
|
||||
|
||||
function tileIsOffered(tile) {
|
||||
const man = registry.get(tile.module);
|
||||
@@ -528,7 +521,6 @@ function reconcileOfferedTiles() {
|
||||
if (changed) rebuildDividers();
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
|
||||
}
|
||||
|
||||
/* A module's entry list can change without anyone saving admin config: PCO
|
||||
@@ -548,90 +540,10 @@ function scheduleOfferedRecheck() {
|
||||
}, OFFERED_RECHECK_MS);
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, each with what
|
||||
to call it and, in a few words, why it is not drawn. */
|
||||
function unshownTiles() {
|
||||
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
|
||||
const man = registry.get(tile.module);
|
||||
if (man) {
|
||||
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
|
||||
return { tile, title, reason: 'not offered right now' };
|
||||
}
|
||||
const off = inactiveModules.get(tile.module);
|
||||
if (off) {
|
||||
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
|
||||
return { tile, title: tile.title || off.name || tile.module, reason };
|
||||
}
|
||||
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
|
||||
});
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, by name. */
|
||||
function unshownTileNames() {
|
||||
return unshownTiles().map((u) => u.title);
|
||||
}
|
||||
|
||||
/* Repaints the Layouts menu's Not shown section while the menu is open —
|
||||
set by unshownSection(), called whenever tiles become offered or stop
|
||||
being, so the list never lags the board behind it. */
|
||||
let repaintUnshown = () => {};
|
||||
|
||||
/** The Layouts menu's Not shown section: one row per tile the layout holds
|
||||
that the board is not drawing — its name, why in a word or two, and a ✕
|
||||
that removes it — present only while there is one. A tile that waits for
|
||||
its module (a Service timer before PCO Plan is up) is listed but can stay;
|
||||
this exists for the one that will never be back, which would otherwise
|
||||
hold its cells with no control anywhere to let them go. */
|
||||
function unshownSection() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'menu-unshown';
|
||||
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
|
||||
let painted = '';
|
||||
const paint = () => {
|
||||
wrap.innerHTML = '';
|
||||
const list = unshownTiles();
|
||||
painted = signature(list);
|
||||
wrap.hidden = !list.length;
|
||||
if (!list.length) return;
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'menu-divider';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'menu-title';
|
||||
title.textContent = 'Not shown';
|
||||
wrap.append(divider, title);
|
||||
for (const { tile, title: name, reason } of list) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'menu-item menu-unshown-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = reason;
|
||||
label.appendChild(sub);
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'menu-x';
|
||||
remove.setAttribute('data-tip', 'Remove');
|
||||
remove.setAttribute('aria-label', `Remove ${name}`);
|
||||
remove.innerHTML = CLOSE_SVG;
|
||||
remove.addEventListener('click', (e) => {
|
||||
// The row goes with the tile, so the click must not reach the
|
||||
// document as one from outside the menu — the menu stays open
|
||||
// to show the next row (or none), as the stepper and Alerts do.
|
||||
e.stopPropagation();
|
||||
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
|
||||
paint();
|
||||
});
|
||||
row.append(label, remove);
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
};
|
||||
paint();
|
||||
// Only when something changed: the registry is re-read every few seconds
|
||||
// while a tile is folded away, and rows rebuilt under the pointer for
|
||||
// nothing would lose the tooltip (and the click) the operator was on.
|
||||
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
|
||||
return wrap;
|
||||
return tiles.filter((t) => !tileEls.has(t.id))
|
||||
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||
}
|
||||
|
||||
function buildTile(tile) {
|
||||
@@ -2341,7 +2253,6 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
// how one is arranged.
|
||||
menu.appendChild(gridStepper());
|
||||
menu.appendChild(alertsFold());
|
||||
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
|
||||
const stepperDivider = document.createElement('div');
|
||||
stepperDivider.className = 'menu-divider';
|
||||
menu.appendChild(stepperDivider);
|
||||
@@ -2433,7 +2344,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||
const note = unshown.length
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||
: '';
|
||||
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||
ms: unshown.length ? 10000 : 7000,
|
||||
|
||||
@@ -368,33 +368,6 @@ body[data-alert="critical"] .tile.is-tinted {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
|
||||
is not drawing, why in a word or two, and the ✕ that removes it */
|
||||
.menu-unshown[hidden] { display: none; }
|
||||
.menu-unshown-row { cursor: default; }
|
||||
.menu-unshown-row:hover { background: none; }
|
||||
.menu-unshown-row > span:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.menu-x {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-x:hover { background: var(--border); color: var(--danger); }
|
||||
.menu-x svg { width: 12px; height: 12px; display: block; }
|
||||
|
||||
/* ── grid ──────────────────────────────────────────────────────────── */
|
||||
|
||||
#gridwrap {
|
||||
|
||||
@@ -1121,6 +1121,11 @@ function parseTar(buf) {
|
||||
while (off + 512 <= buf.length) {
|
||||
const h = buf.subarray(off, off + 512);
|
||||
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
||||
// The mode matters: modules ship prebuilt helpers under bin/, and writing
|
||||
// one out without its execute bits leaves spawn() with EACCES and the
|
||||
// feature silently dead. Permission bits only — setuid/sticky from an
|
||||
// archive are not wanted here.
|
||||
const mode = (parseInt(cstr(h, 100, 8).trim() || '0', 8) & 0o777) || 0o644;
|
||||
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
||||
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
||||
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
||||
@@ -1136,7 +1141,7 @@ function parseTar(buf) {
|
||||
}
|
||||
const name = pendingName || shortName;
|
||||
pendingName = null;
|
||||
if (type === '0') files.set(name, Buffer.from(data));
|
||||
if (type === '0') files.set(name, { data: Buffer.from(data), mode });
|
||||
}
|
||||
return { files, comment };
|
||||
}
|
||||
@@ -1165,9 +1170,9 @@ async function fetchArchive() {
|
||||
}
|
||||
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||
const stripped = new Map(); // drop the archive's single top-level folder
|
||||
for (const [p, data] of files) {
|
||||
for (const [p, entry] of files) {
|
||||
const i = p.indexOf('/');
|
||||
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
|
||||
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry);
|
||||
}
|
||||
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
||||
}
|
||||
@@ -1182,17 +1187,17 @@ async function refreshRemote(force = false) {
|
||||
const job = (async () => {
|
||||
try {
|
||||
const { files, sha } = await fetchArchive();
|
||||
const pkg = safeJson(files.get('package.json'));
|
||||
const pkg = safeJson(files.get('package.json')?.data);
|
||||
const modules = new Map();
|
||||
for (const [p, data] of files) {
|
||||
for (const [p, file] of files) {
|
||||
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
||||
if (!m) continue;
|
||||
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
||||
entry.files.set(m[2], data);
|
||||
entry.files.set(m[2], file);
|
||||
modules.set(m[1], entry);
|
||||
}
|
||||
for (const [id, entry] of modules) {
|
||||
const man = safeJson(entry.files.get('module.json'));
|
||||
const man = safeJson(entry.files.get('module.json')?.data);
|
||||
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
||||
modules.delete(id);
|
||||
continue;
|
||||
@@ -1322,12 +1327,14 @@ async function installModule(id) {
|
||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
const shipped = new Set();
|
||||
for (const [rel, data] of remoteEntry.files) {
|
||||
for (const [rel, { data, mode }] of remoteEntry.files) {
|
||||
if (!safeRelPath(rel)) continue;
|
||||
shipped.add(rel);
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
// The tarball's mode, or a shipped bin/ helper lands 0644 and spawns
|
||||
// EACCES (what broke LTC and SPL on the 1.10.2 rollout, 2026-10-04).
|
||||
fs.writeFileSync(target, data, { mode });
|
||||
}
|
||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||
// Per-machine executables — a capture tool compiled here, or copied in
|
||||
@@ -1439,11 +1446,11 @@ async function updateShell() {
|
||||
} else {
|
||||
const staging = path.join(ROOT, '.proddash-update');
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
for (const [rel, data] of remote.files) {
|
||||
for (const [rel, { data, mode }] of remote.files) {
|
||||
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
fs.writeFileSync(target, data, { mode });
|
||||
}
|
||||
const newServer = path.join(staging, 'server.js');
|
||||
if (!fs.existsSync(newServer)) {
|
||||
@@ -2336,17 +2343,8 @@ function handleRequest(req, res) {
|
||||
|
||||
/* — shell API — */
|
||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||
// Beside what dashboards may load: the installed modules that are not
|
||||
// running, by id and name only, so a tile one of them left in a layout
|
||||
// can say why it is not drawn ("module disabled") instead of guessing at
|
||||
// "not installed". Nothing of their config travels with it.
|
||||
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
|
||||
id,
|
||||
name: String(manifests.get(id).name || id),
|
||||
reason: isEnabled(id) ? 'incompatible' : 'disabled',
|
||||
}));
|
||||
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||
.then((list) => sendJson(res, 200, { modules: list, inactive }))
|
||||
.then((list) => sendJson(res, 200, { modules: list }))
|
||||
.catch((err) => {
|
||||
console.error('[proddash] /api/modules failed:', err);
|
||||
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
||||
|
||||
+22
-1
@@ -12,7 +12,12 @@
|
||||
* 2. a shell update from an archive that carries modules/ never writes
|
||||
* them into the app folder — they show up in the catalog instead;
|
||||
* 3. a fresh install lists everything the repo offers, and installing
|
||||
* several at once lands them in the data directory and mounts them.
|
||||
* several at once lands them in the data directory and mounts them;
|
||||
* 4. a module downloaded from the catalog keeps the tarball's modes — a
|
||||
* shipped bin/ helper is still executable (0644 means spawn EACCES,
|
||||
* which is what broke LTC and SPL on the 1.10.2 rollout) — while
|
||||
* per-machine executables the download does not include are still
|
||||
* carried over from the copy being replaced.
|
||||
*
|
||||
* node tools/unbundled.test.js
|
||||
*
|
||||
@@ -70,6 +75,11 @@ function buildArchive(dir) {
|
||||
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
||||
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
||||
// The repo ships this one prebuilt (the way Timers and SPL ship their
|
||||
// capture helpers): it must come out of the download still executable.
|
||||
fs.mkdirSync(path.join(top, 'modules', 'demo', 'bin'));
|
||||
fs.writeFileSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), '#!/bin/sh\necho capture\n');
|
||||
fs.chmodSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), 0o755);
|
||||
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
||||
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
||||
const out = path.join(dir, 'main.tar.gz');
|
||||
@@ -188,6 +198,17 @@ function buildPackagedApp(app) {
|
||||
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
||||
assert.equal(empty.status, 400, 'nothing to install is said');
|
||||
|
||||
// ── 4. a download keeps executable modes, and carry-over still works ──
|
||||
const upd = await api('/api/admin/modules/demo/install', { method: 'POST', body: '{}' });
|
||||
assert.equal(upd.status, 200, 'demo update: ' + JSON.stringify(upd.body));
|
||||
assert.equal(upd.body.version, '2.0.0', 'demo was updated from the repo');
|
||||
const shipped = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-capture');
|
||||
assert.ok(fs.existsSync(shipped), 'the downloaded helper is there');
|
||||
assert.ok(fs.statSync(shipped).mode & 0o111, 'and the tarball\'s execute bit survived the install');
|
||||
const carried = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-helper');
|
||||
assert.ok(fs.existsSync(carried), 'the per-machine executable the download lacks was carried over');
|
||||
assert.ok(fs.statSync(carried).mode & 0o111, 'still executable');
|
||||
|
||||
console.log('unbundled shell: every check passed');
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A stand-in for the ProPresenter Mac's network MIDI session, for the Vista
|
||||
* Cues module: an RTP-MIDI (AppleMIDI) session that accepts invitations and
|
||||
* plays a cue sheet's notes to everyone in it, the way ProPresenter's MIDI
|
||||
* actions reach Vista.
|
||||
*
|
||||
* node tools/vista-midi-mock.js 5104 # wait to be invited on UDP 5104/5105
|
||||
* node tools/vista-midi-mock.js 5104 --invite 127.0.0.1:5040
|
||||
* # …or invite ProdDash yourself
|
||||
* --name "Tracks Laptop" session name (default "ProPresenter Mock")
|
||||
* --every 4 seconds between cues (default 4)
|
||||
* --channel 1 MIDI channel (default 1)
|
||||
* --notes 2,23,8,30 play these notes in a loop instead of the built-in sheet's
|
||||
*
|
||||
* Point Vista Cues' "First sender" at 127.0.0.1 port 5104 and paste the
|
||||
* sheet this prints into "Cue lists". Kill it to watch the tile go red;
|
||||
* start it again to watch it come back.
|
||||
*
|
||||
* Written separately from the module's own rtpmidi.js on purpose, so a bug
|
||||
* there is not mirrored here.
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (name, dflt) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 5104;
|
||||
const NAME = opt('name', 'ProPresenter Mock');
|
||||
const EVERY = Number(opt('every', 4)) * 1000;
|
||||
const CHANNEL = Math.min(16, Math.max(1, Number(opt('channel', 1)))) - 1;
|
||||
const INVITE = opt('invite', '');
|
||||
|
||||
const SHEET = [
|
||||
'60 = Walk-in',
|
||||
'61 = Worship 1',
|
||||
'62 = Worship 2',
|
||||
'63 = Message',
|
||||
'64 pause Worship 2',
|
||||
'70 release Walk-in',
|
||||
'71 release Worship 1',
|
||||
'127 release all',
|
||||
];
|
||||
const NOTES = opt('notes', '');
|
||||
const SCRIPT = NOTES ? NOTES.split(',').map(Number).filter((n) => n >= 0 && n <= 127) : [60, 61, 70, 62, 64, 64, 71, 90, 63, 127];
|
||||
|
||||
const ssrc = (Math.random() * 0xffffffff) >>> 0;
|
||||
const t0 = process.hrtime.bigint();
|
||||
const now = () => (process.hrtime.bigint() - t0) / 100000n;
|
||||
let seq = Math.floor(Math.random() * 0xffff);
|
||||
|
||||
const peers = new Map(); // ssrc → { address, controlPort, dataPort, token, name, ready }
|
||||
|
||||
function session(cmd, token, name) {
|
||||
const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`);
|
||||
const b = Buffer.alloc(16 + n.length);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write(cmd, 2, 'ascii');
|
||||
b.writeUInt32BE(2, 4);
|
||||
b.writeUInt32BE(token >>> 0, 8);
|
||||
b.writeUInt32BE(ssrc, 12);
|
||||
n.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
function ck(count, ts) {
|
||||
const b = Buffer.alloc(36);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write('CK', 2, 'ascii');
|
||||
b.writeUInt32BE(ssrc, 4);
|
||||
b[8] = count;
|
||||
ts.forEach((t, i) => b.writeBigUInt64BE(BigInt.asUintN(64, t), 12 + i * 8));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** One RTP-MIDI packet: note on, then (delta 0, running status) the note off. */
|
||||
function notePacket(note) {
|
||||
const midi = Buffer.from([0x90 | CHANNEL, note, 100, 0x00, note, 0x00]);
|
||||
const b = Buffer.alloc(12 + 1 + midi.length);
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x61;
|
||||
seq = (seq + 1) & 0xffff;
|
||||
b.writeUInt16BE(seq, 2);
|
||||
b.writeUInt32BE(Number(now() & 0xffffffffn), 4);
|
||||
b.writeUInt32BE(ssrc, 8);
|
||||
b[12] = midi.length; // B=0 J=0 Z=0 P=0, LEN
|
||||
midi.copy(b, 13);
|
||||
return b;
|
||||
}
|
||||
|
||||
const control = dgram.createSocket('udp4');
|
||||
const data = dgram.createSocket('udp4');
|
||||
|
||||
function onPacket(sock, which) {
|
||||
return (buf, r) => {
|
||||
if (buf.length < 4 || buf.readUInt16BE(0) !== 0xffff) return;
|
||||
const cmd = buf.toString('ascii', 2, 4);
|
||||
if (cmd === 'IN') {
|
||||
const token = buf.readUInt32BE(8);
|
||||
const them = buf.readUInt32BE(12);
|
||||
const end = buf.indexOf(0, 16);
|
||||
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
sock.send(session('OK', token, NAME), r.port, r.address);
|
||||
const p = peers.get(them) || { address: r.address, token, name, ready: false };
|
||||
if (which === 'control') p.controlPort = r.port;
|
||||
else { p.dataPort = r.port; p.ready = true; console.log(`[mock] "${name}" joined from ${r.address}`); }
|
||||
peers.set(them, p);
|
||||
} else if (cmd === 'OK') {
|
||||
// Our own invitation (--invite) was accepted.
|
||||
const them = buf.readUInt32BE(12);
|
||||
const end = buf.indexOf(0, 16);
|
||||
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
if (which === 'control') {
|
||||
pending.stage = 'data';
|
||||
data.send(session('IN', pending.token, NAME), pending.port + 1, pending.host);
|
||||
} else {
|
||||
pending.stage = 'done';
|
||||
peers.set(them, { address: r.address, controlPort: pending.port, dataPort: r.port, token: pending.token, name, ready: true });
|
||||
console.log(`[mock] joined "${name}" at ${r.address}:${pending.port}`);
|
||||
}
|
||||
} else if (cmd === 'BY') {
|
||||
const them = buf.readUInt32BE(12);
|
||||
if (peers.delete(them)) console.log('[mock] a participant left');
|
||||
} else if (cmd === 'CK') {
|
||||
const count = buf[8];
|
||||
const ts = [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)];
|
||||
if (count === 0) data.send(ck(1, [ts[0], now(), 0n]), r.port, r.address);
|
||||
else if (count === 1) data.send(ck(2, [ts[0], ts[1], now()]), r.port, r.address);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pending = { stage: '', host: '', port: 0, token: 0 };
|
||||
|
||||
control.on('message', onPacket(control, 'control'));
|
||||
data.on('message', onPacket(data, 'data'));
|
||||
control.bind(PORT, () => data.bind(PORT + 1, () => {
|
||||
console.log(`[mock] session "${NAME}" on UDP ${PORT}/${PORT + 1}, channel ${CHANNEL + 1}`);
|
||||
console.log('[mock] cue sheet for Vista Cues → Cue lists:\n ' + SHEET.join('\n '));
|
||||
if (INVITE) {
|
||||
const [host, port] = INVITE.split(':');
|
||||
Object.assign(pending, { stage: 'control', host, port: Number(port) || 5040, token: (Math.random() * 0xffffffff) >>> 0 });
|
||||
const tryInvite = () => {
|
||||
if (pending.stage === 'done') return;
|
||||
pending.stage = 'control';
|
||||
control.send(session('IN', pending.token, NAME), pending.port, pending.host);
|
||||
setTimeout(tryInvite, 2000);
|
||||
};
|
||||
tryInvite();
|
||||
}
|
||||
}));
|
||||
|
||||
// Clock sync, as the session that started it would do (harmless otherwise).
|
||||
setInterval(() => {
|
||||
for (const p of peers.values()) if (p.ready) data.send(ck(0, [now(), 0n, 0n]), p.dataPort, p.address);
|
||||
}, 10000);
|
||||
|
||||
let step = 0;
|
||||
setInterval(() => {
|
||||
const ready = [...peers.values()].filter((p) => p.ready);
|
||||
if (!ready.length) return;
|
||||
const note = SCRIPT[step % SCRIPT.length];
|
||||
step += 1;
|
||||
const pkt = notePacket(note);
|
||||
for (const p of ready) data.send(pkt, p.dataPort, p.address);
|
||||
console.log(`[mock] note ${note} → ${ready.length} participant${ready.length === 1 ? '' : 's'}`);
|
||||
}, EVERY);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
for (const p of peers.values()) control.send(session('BY', p.token), p.controlPort, p.address);
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
# ProdDash Viewer for macOS
|
||||
|
||||
A Mac app whose only job is to show ProdDash. It opens one installation's
|
||||
dashboard in its own window — or several, with a slim tab bar to switch
|
||||
between them — and does nothing else: no address bar, no bookmarks, no
|
||||
browser to wander off in. It is for the screens that just *show* a dashboard:
|
||||
a booth display, a stage-manager's laptop, a second Mac watching the other
|
||||
campus.
|
||||
|
||||
It runs no server. ProdDash itself is installed with the menu-bar app in
|
||||
[launcher/macos](../../launcher/macos); the Viewer points at that, or at any
|
||||
other ProdDash on the network.
|
||||
|
||||
Apple silicon, macOS 13 or newer. Native Swift (AppKit with a little SwiftUI,
|
||||
WebKit for the page), no dependencies, about 1 MB.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./build.sh # build/ProdDash Viewer.app
|
||||
./build.sh --dmg # …and build/ProdDash-Viewer-<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)
|
||||
```
|
||||
|
||||
Needs Xcode, selected with `xcode-select` — with only the command line
|
||||
tools, the macOS 27 SDK can't expand SwiftUI's `@State` macro and the
|
||||
compile fails. Signing and notarising work exactly as
|
||||
for ProdDash's own app — a Developer ID certificate when one is installed,
|
||||
ad-hoc otherwise, and the same `proddash` notarytool profile; see
|
||||
[launcher/macos/README.md](../../launcher/macos/README.md#signing). The
|
||||
Viewer keeps its own version number (`VERSION` in `build.sh`), since it works
|
||||
with any ProdDash rather than shipping with one.
|
||||
|
||||
The icon is `Resources/AppIcon.png`, drawn by `Resources/make-icon.swift` —
|
||||
ProdDash's tile mark on a display, so the two apps tell apart in Finder.
|
||||
Run `swift Resources/make-icon.swift Resources/AppIcon.png` to redraw it.
|
||||
|
||||
## Using it
|
||||
|
||||
**Settings…** (⌘,) → **Connections** → **+**: a name (what the tab says) and
|
||||
the address you open ProdDash at in a browser, such as `10.3.11.152:24500`.
|
||||
Without a port, ProdDash's 24500 is assumed; a path is kept, so an address
|
||||
can open a particular page. The sheet asks the address whether ProdDash is
|
||||
answering as you type, but saves either way — the installation may simply be
|
||||
switched off right now. Double-click a connection to change it, drag to
|
||||
reorder the tabs.
|
||||
|
||||
With two or more connections the tab bar appears. In a window it floats over
|
||||
the top of the dashboard, comes out while the pointer is on the title bar and
|
||||
tucks away after; in full screen it comes down with the menu bar, and shows
|
||||
for a moment after each switch. ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 switch from the
|
||||
keyboard. A tab marked ⚠︎ is an installation that isn't answering.
|
||||
|
||||
The menus are short on purpose: **View** has Back (⌘[), Forward (⌘]), Reload
|
||||
Page (⌘R) and Enter Full Screen; the rest is what every Mac app has. **Edit**
|
||||
is there because without it Copy and Paste do nothing in the dashboard's and
|
||||
the admin page's text fields. **Help** opens the guide that ships in the app.
|
||||
|
||||
**Settings → General**: **Open at login**, and **Keep the display awake**
|
||||
(on by default) — while a dashboard is on screen, the display doesn't dim or
|
||||
sleep. Minimised, hidden or covered, it lets go.
|
||||
|
||||
## How it behaves
|
||||
|
||||
- **Every dashboard stays live.** Each connection has its own web view, kept
|
||||
running behind the one on screen, so switching is instant and nothing has
|
||||
to reconnect.
|
||||
- **The page never jumps.** In a window the tab bar is an overlay rather than
|
||||
part of the title bar: anything in the title bar makes WebKit push the
|
||||
whole page down to clear it, and a dashboard re-flowing every time the bar
|
||||
came and went would be worse than no bar. In full screen it *is* a title
|
||||
bar accessory — there the title bar lives in a window of its own, and the
|
||||
page stays put.
|
||||
- **It comes back by itself.** An installation that can't be reached shows
|
||||
why (nothing answering, can't be found, no network — including macOS
|
||||
withholding Local Network access) and is retried at 2, 4, 8 and then every
|
||||
15 seconds. Once a dashboard has loaded, ProdDash's own service worker and
|
||||
reconnect logic carry it through a server restart.
|
||||
- **It comes back as it was left.** The window's size and place, the tab that
|
||||
was showing, and full screen: quit in full screen and it reopens in full
|
||||
screen — so a booth Mac set to log in and open the Viewer at login returns
|
||||
to the dashboard on its own after a restart.
|
||||
- **It stays on ProdDash.** Links to the installation's own pages open in
|
||||
the window; links anywhere else (a Planning Center item, the release notes)
|
||||
open in the default browser, as do downloads. The page's `alert`,
|
||||
`confirm` and `prompt` — the admin page confirms before deleting a layout,
|
||||
Band Lineup prompts for names — show as sheets titled with the
|
||||
installation's name.
|
||||
- **The dashboard's own full-screen button works**, in addition to the
|
||||
window's: it fills the screen with the dashboard alone; Esc leaves.
|
||||
- Closing the window quits the app, as with System Settings — there is only
|
||||
ever the one window.
|
||||
- Safari's **Develop** menu can inspect a dashboard running in the Viewer.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
Sources/
|
||||
main.swift NSApplication, nothing else
|
||||
AppDelegate.swift lifecycle and the menus
|
||||
ViewerWindowController.swift the window: pages, tab bar overlay/accessory,
|
||||
full screen, keyboard switching, display awake
|
||||
DashboardPage.swift one connection's web view, status view, retry,
|
||||
navigation policy, JavaScript dialogs
|
||||
TabBar.swift the tabs (SwiftUI), shared by overlay and accessory
|
||||
Connections.swift Connection, address rules, the store, the probe
|
||||
ViewerSettings.swift preferences and the login item
|
||||
SettingsWindowController.swift Settings: Connections and General
|
||||
HelpWindowController.swift Help → ProdDash Viewer Help
|
||||
Resources/
|
||||
Info.plist bundle id org.waterschurch.proddash.viewer
|
||||
Help/index.html the guide shown by the Help menu
|
||||
AppIcon.png, make-icon.swift the icon and what draws it
|
||||
```
|
||||
|
||||
Preferences live in `~/Library/Preferences/org.waterschurch.proddash.viewer.plist`
|
||||
(connections, the selected tab, full screen, keep awake); the web views'
|
||||
storage — each dashboard's own per-browser settings — in WebKit's default
|
||||
store for the app.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
@@ -1,78 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProdDash Viewer Help</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; --muted: #6e6e73; --rule: #d2d2d7; --key: #f2f2f7; }
|
||||
@media (prefers-color-scheme: dark) { :root { --muted: #98989d; --rule: #3a3a3c; --key: #2c2c2e; } }
|
||||
body { font: 13px/1.55 -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 28px 36px 48px; max-width: 640px; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; margin: 28px 0 6px; padding-top: 18px; border-top: 1px solid var(--rule); }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 20px; }
|
||||
.lead { color: var(--muted); margin-bottom: 18px; }
|
||||
kbd { font: 12px -apple-system, sans-serif; background: var(--key); border: 1px solid var(--rule); border-radius: 4px; padding: 0 5px; }
|
||||
code { font: 12px ui-monospace, Menlo, monospace; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
|
||||
td { padding: 5px 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
||||
td:first-child { width: 42%; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>ProdDash Viewer</h1>
|
||||
<p class="lead">A window onto ProdDash. It shows one installation’s dashboard — or several, with a tab for each — and nothing else.</p>
|
||||
|
||||
<h2>Add an installation</h2>
|
||||
<ul>
|
||||
<li>Choose <b>ProdDash Viewer → Settings…</b> (<kbd>⌘,</kbd>), then <b>Connections</b>, then <b>+</b>.</li>
|
||||
<li><b>Name</b> is what its tab says — “AB Booth”, “NA Ingest”.</li>
|
||||
<li><b>Address</b> is the one you open ProdDash at in a browser, such as <code>10.3.11.152:24500</code>. Leave the port off and ProdDash’s own, 24500, is used. The sheet tells you as you type whether ProdDash is answering there.</li>
|
||||
<li>Double-click a connection to change it; drag connections to change the order of the tabs.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Switch between installations</h2>
|
||||
<p>With two or more connections, a slim tab bar of their names sits under the title bar. It tucks itself away so the dashboard has the whole window: point at the title bar and it slides back out.</p>
|
||||
<ul>
|
||||
<li>In full screen, move the pointer to the top of the screen — the tab bar comes down with the menu bar. It also shows for a moment after each switch, so you can see where you are.</li>
|
||||
<li><kbd>⌃Tab</kbd> and <kbd>⌃⇧Tab</kbd> go to the next and previous installation; <kbd>⌘1</kbd>–<kbd>⌘8</kbd> go straight to one, <kbd>⌘9</kbd> to the last.</li>
|
||||
<li>Every dashboard stays live in the background, so switching is instant.</li>
|
||||
<li>A tab with <b>⚠︎</b> is an installation that isn’t answering.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Full screen</h2>
|
||||
<p>Click the green button, or choose <b>View → Enter Full Screen</b> (<kbd>fn F</kbd> or <kbd>⌃⌘F</kbd>). If the Viewer was in full screen when it quit, it opens in full screen again — so a booth Mac that restarts comes back as it was left.</p>
|
||||
<p>The dashboard’s own full-screen button works too: it fills the screen with the dashboard alone, tab bar and all put away. Press <kbd>Esc</kbd> to leave it.</p>
|
||||
|
||||
<h2>Moving around</h2>
|
||||
<ul>
|
||||
<li><b>View → Back</b> (<kbd>⌘[</kbd>), <b>Forward</b> (<kbd>⌘]</kbd>) — after opening the admin page from the dashboard, for example. Swiping with two fingers works as in Safari.</li>
|
||||
<li><b>View → Reload Page</b> (<kbd>⌘R</kbd>).</li>
|
||||
<li>Links to anywhere outside the installation open in your web browser.</li>
|
||||
</ul>
|
||||
|
||||
<h2>When an installation can’t be reached</h2>
|
||||
<p>The Viewer says why — nothing answering, the address can’t be found, no network — and keeps trying by itself, so a dashboard comes back on its own after ProdDash restarts or its Mac reboots. <b>Try Again</b> asks right away.</p>
|
||||
<p>If macOS asked whether ProdDash Viewer may find devices on your local network and the answer was no, it can’t reach anything: allow it in <b>System Settings → Privacy & Security → Local Network</b>.</p>
|
||||
|
||||
<h2>General settings</h2>
|
||||
<ul>
|
||||
<li><b>Open at login</b> — start the Viewer when this Mac’s user logs in. macOS may ask you to approve it in System Settings first.</li>
|
||||
<li><b>Keep the display awake</b> — while a dashboard is on screen, the display doesn’t dim or sleep. On unless you turn it off.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<table>
|
||||
<tr><td><kbd>⌘,</kbd></td><td>Settings</td></tr>
|
||||
<tr><td><kbd>⌘[</kbd> <kbd>⌘]</kbd></td><td>Back, Forward</td></tr>
|
||||
<tr><td><kbd>⌘R</kbd></td><td>Reload Page</td></tr>
|
||||
<tr><td><kbd>fn F</kbd> <kbd>⌃⌘F</kbd></td><td>Enter or leave full screen</td></tr>
|
||||
<tr><td><kbd>⌃Tab</kbd> <kbd>⌃⇧Tab</kbd></td><td>Next, previous installation</td></tr>
|
||||
<tr><td><kbd>⌘1</kbd> … <kbd>⌘9</kbd></td><td>An installation by its place in the tab bar</td></tr>
|
||||
<tr><td><kbd>⌘W</kbd></td><td>Close the window, which quits the Viewer</td></tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +0,0 @@
|
||||
<?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 Viewer</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ProdDash Viewer</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ProdDashViewer</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.waterschurch.proddash.viewer</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 Waters Church</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSArchitecturePriority</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<!-- ProdDash is served over plain http on the local network, and the
|
||||
connection sheet asks each address whether ProdDash is there. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<!-- macOS shows this sentence verbatim when it asks. -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>ProdDash Viewer shows the dashboards of the ProdDash installations you add in Settings, which run on this network.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,78 +0,0 @@
|
||||
// Draws AppIcon.png, the Viewer's icon: ProdDash's 2×2 tile mark on a
|
||||
// display, so the Viewer and ProdDash itself tell apart in Finder and the
|
||||
// Dock. Run it again only to change the icon; build.sh uses the PNG.
|
||||
//
|
||||
// swift Resources/make-icon.swift Resources/AppIcon.png
|
||||
import AppKit
|
||||
|
||||
let side = 1024
|
||||
let output = CommandLine.arguments.dropFirst().first ?? "AppIcon.png"
|
||||
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
|
||||
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0, bitsPerPixel: 0),
|
||||
let context = NSGraphicsContext(bitmapImageRep: bitmap) else { fatalError("no bitmap") }
|
||||
NSGraphicsContext.current = context
|
||||
let g = context.cgContext
|
||||
// Top-left origin, as the measurements below are written.
|
||||
g.translateBy(x: 0, y: CGFloat(side))
|
||||
g.scaleBy(x: 1, y: -1)
|
||||
|
||||
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
|
||||
CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255, green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
|
||||
}
|
||||
func rounded(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
|
||||
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
||||
}
|
||||
func fill(_ path: CGPath, _ fillColor: CGColor) {
|
||||
g.addPath(path); g.setFillColor(fillColor); g.fillPath()
|
||||
}
|
||||
|
||||
// The body: Apple's icon grid — an 824-point rounded square, centred, on the
|
||||
// soft shadow macOS icons sit on. ProdDash's own near-black.
|
||||
let body = CGRect(x: 100, y: 100, width: 824, height: 824)
|
||||
let bodyPath = rounded(body, 185)
|
||||
g.saveGState()
|
||||
g.setShadow(offset: CGSize(width: 0, height: 12), blur: 28, color: color(0x000000, 0.35))
|
||||
fill(bodyPath, color(0x0b0e12))
|
||||
g.restoreGState()
|
||||
g.saveGState()
|
||||
g.addPath(bodyPath); g.clip()
|
||||
let shade = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB),
|
||||
colors: [color(0x1b222b), color(0x0a0d11)] as CFArray, locations: [0, 1])!
|
||||
g.drawLinearGradient(shade, start: CGPoint(x: 0, y: body.minY), end: CGPoint(x: 0, y: body.maxY), options: [])
|
||||
g.restoreGState()
|
||||
g.addPath(rounded(body.insetBy(dx: 1.5, dy: 1.5), 183.5))
|
||||
g.setStrokeColor(color(0xffffff, 0.08)); g.setLineWidth(3); g.strokePath()
|
||||
|
||||
// The display: bezel, screen, neck and foot.
|
||||
let bezel = CGRect(x: 212, y: 250, width: 600, height: 410)
|
||||
let metal = color(0x3a434f)
|
||||
fill(rounded(CGRect(x: 474, y: 650, width: 76, height: 84), 8), metal)
|
||||
fill(rounded(CGRect(x: 372, y: 724, width: 280, height: 36), 18), color(0x4a5462))
|
||||
fill(rounded(bezel, 44), metal)
|
||||
let screen = bezel.insetBy(dx: 22, dy: 22)
|
||||
fill(rounded(screen, 26), color(0x0c1015))
|
||||
|
||||
// ProdDash's mark on the screen: three outlined tiles and the lit one.
|
||||
let padding: CGFloat = 46, gap: CGFloat = 30
|
||||
let tileW = (screen.width - padding * 2 - gap) / 2
|
||||
let tileH = (screen.height - padding * 2 - gap) / 2
|
||||
for (column, row) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let tile = CGRect(x: screen.minX + padding + CGFloat(column) * (tileW + gap),
|
||||
y: screen.minY + padding + CGFloat(row) * (tileH + gap),
|
||||
width: tileW, height: tileH)
|
||||
if column == 1 && row == 1 {
|
||||
fill(rounded(tile, 22), color(0x2fe39a))
|
||||
} else {
|
||||
let inset = tile.insetBy(dx: 5, dy: 5)
|
||||
fill(rounded(inset, 18), color(0x121820))
|
||||
g.addPath(rounded(inset, 18)); g.setStrokeColor(color(0x3d4652)); g.setLineWidth(10); g.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else { fatalError("no png") }
|
||||
try png.write(to: URL(fileURLWithPath: output))
|
||||
print("wrote \(output)")
|
||||
@@ -1,110 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
/**
|
||||
ProdDash Viewer: one window showing a ProdDash dashboard, a tab per
|
||||
installation when there's more than one, and Settings to say which.
|
||||
|
||||
The menus are deliberately short — Back, Forward, Reload Page and Settings,
|
||||
plus what every Mac app has: the app menu, Edit (without which Copy and
|
||||
Paste do nothing in the dashboard's text fields), Window and Help.
|
||||
*/
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
static var version: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
}
|
||||
|
||||
private var viewer: ViewerWindowController!
|
||||
private lazy var settingsWindow = SettingsWindowController()
|
||||
private lazy var helpWindow = HelpWindowController()
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
NSApp.mainMenu = makeMainMenu()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
viewer = ViewerWindowController()
|
||||
viewer.onAddConnection = { [weak self] in self?.settingsWindow.show(addingConnection: true) }
|
||||
viewer.showWindow(nil)
|
||||
if ViewerSettings.shared.reopenInFullScreen {
|
||||
DispatchQueue.main.async { self.viewer.window?.toggleFullScreen(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if !flag { viewer.showWindow(nil) }
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
viewer?.rememberFullScreen()
|
||||
}
|
||||
|
||||
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
|
||||
|
||||
@objc func showSettings(_ sender: Any?) { settingsWindow.show() }
|
||||
@objc func showViewerHelp(_ sender: Any?) { helpWindow.show() }
|
||||
|
||||
// MARK: - Menus
|
||||
|
||||
private func makeMainMenu() -> NSMenu {
|
||||
let name = "ProdDash Viewer"
|
||||
let main = NSMenu()
|
||||
|
||||
let app = submenu(of: main, title: name)
|
||||
app.addItem(withTitle: "About \(name)",
|
||||
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
|
||||
app.addItem(.separator())
|
||||
app.addItem(withTitle: "Settings…", action: #selector(showSettings(_:)), keyEquivalent: ",")
|
||||
app.addItem(.separator())
|
||||
let services = submenu(of: app, title: "Services")
|
||||
NSApp.servicesMenu = services
|
||||
app.addItem(.separator())
|
||||
app.addItem(withTitle: "Hide \(name)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||
app.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)),
|
||||
keyEquivalent: "h").keyEquivalentModifierMask = [.command, .option]
|
||||
app.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
|
||||
app.addItem(.separator())
|
||||
app.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
|
||||
let edit = submenu(of: main, title: "Edit")
|
||||
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
|
||||
edit.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
|
||||
edit.addItem(.separator())
|
||||
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||
|
||||
let view = submenu(of: main, title: "View")
|
||||
view.addItem(withTitle: "Back", action: #selector(ViewerWindowController.goBackInPage(_:)), keyEquivalent: "[")
|
||||
view.addItem(withTitle: "Forward", action: #selector(ViewerWindowController.goForwardInPage(_:)), keyEquivalent: "]")
|
||||
view.addItem(withTitle: "Reload Page", action: #selector(ViewerWindowController.reloadPage(_:)), keyEquivalent: "r")
|
||||
view.addItem(.separator())
|
||||
// AppKit retitles this "Exit Full Screen" while the window is in full screen.
|
||||
view.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)),
|
||||
keyEquivalent: "f").keyEquivalentModifierMask = [.command, .control]
|
||||
|
||||
let window = submenu(of: main, title: "Window")
|
||||
window.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||
window.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
|
||||
window.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
|
||||
window.addItem(.separator())
|
||||
window.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
|
||||
NSApp.windowsMenu = window
|
||||
|
||||
let help = submenu(of: main, title: "Help")
|
||||
help.addItem(withTitle: "\(name) Help", action: #selector(showViewerHelp(_:)), keyEquivalent: "?")
|
||||
NSApp.helpMenu = help
|
||||
|
||||
return main
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func submenu(of parent: NSMenu, title: String) -> NSMenu {
|
||||
let menu = NSMenu(title: title)
|
||||
parent.addItem(withTitle: title, action: nil, keyEquivalent: "").submenu = menu
|
||||
return menu
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// One ProdDash installation the viewer shows: the name its tab carries and
|
||||
/// the address it answers at.
|
||||
struct Connection: Codable, Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
/// Always a full http(s) URL — see `Address.normalize`.
|
||||
var address: String
|
||||
|
||||
var url: URL? { URL(string: address) }
|
||||
|
||||
/// The name, or the host when nobody gave it one.
|
||||
var displayName: String {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed.isEmpty ? (url?.host ?? address) : trimmed
|
||||
}
|
||||
|
||||
var displayAddress: String { url.map(Address.display) ?? address }
|
||||
}
|
||||
|
||||
enum Address {
|
||||
/// ProdDash's port unless someone changed it (config/proddash.json).
|
||||
static let defaultPort = 24500
|
||||
|
||||
/// What someone typed, as the URL ProdDash answers at: `10.3.11.152` and
|
||||
/// `10.3.11.152:24500` both become `http://10.3.11.152:24500/`. A typed
|
||||
/// scheme is taken at its word — `http://host` means port 80. A path is
|
||||
/// kept, so an address can open a particular page.
|
||||
static func normalize(_ text: String) -> URL? {
|
||||
var typed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !typed.isEmpty else { return nil }
|
||||
let hasScheme = typed.contains("://")
|
||||
if !hasScheme { typed = "http://" + typed }
|
||||
guard var parts = URLComponents(string: typed),
|
||||
let scheme = parts.scheme?.lowercased(), scheme == "http" || scheme == "https",
|
||||
let host = parts.host, !host.isEmpty else { return nil }
|
||||
parts.scheme = scheme
|
||||
if !hasScheme && parts.port == nil { parts.port = defaultPort }
|
||||
if parts.path.isEmpty { parts.path = "/" }
|
||||
return parts.url
|
||||
}
|
||||
|
||||
/// Scheme, host and port — what decides whether a link stays in the viewer.
|
||||
static func origin(_ url: URL) -> String? {
|
||||
guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil }
|
||||
let port = url.port ?? (scheme == "https" ? 443 : 80)
|
||||
return "\(scheme)://\(host):\(port)"
|
||||
}
|
||||
|
||||
/// For people to read: no trailing slash on a bare address.
|
||||
static func display(_ url: URL) -> String {
|
||||
var text = url.absoluteString
|
||||
if url.path == "/" && url.query == nil && url.fragment == nil && text.hasSuffix("/") { text.removeLast() }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Just the scheme, host and port, for people: `https://host`.
|
||||
static func originDisplay(_ url: URL) -> String {
|
||||
var parts = URLComponents()
|
||||
parts.scheme = url.scheme
|
||||
parts.host = url.host
|
||||
parts.port = url.port
|
||||
return parts.string ?? url.absoluteString
|
||||
}
|
||||
|
||||
/// `host:port`, the way the error messages name a place.
|
||||
static func place(_ url: URL?) -> String {
|
||||
guard let url, let host = url.host else { return "that address" }
|
||||
return url.port.map { "\(host):\($0)" } ?? host
|
||||
}
|
||||
}
|
||||
|
||||
/// The connections, in tab order, kept in the viewer's preferences.
|
||||
final class ConnectionStore: ObservableObject {
|
||||
|
||||
static let shared = ConnectionStore()
|
||||
private static let key = "connections"
|
||||
|
||||
@Published private(set) var connections: [Connection] {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
private init() {
|
||||
let saved = UserDefaults.standard.data(forKey: Self.key)
|
||||
connections = saved.flatMap { try? JSONDecoder().decode([Connection].self, from: $0) } ?? []
|
||||
}
|
||||
|
||||
func add(_ connection: Connection) { connections.append(connection) }
|
||||
|
||||
func update(_ connection: Connection) {
|
||||
guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return }
|
||||
connections[index] = connection
|
||||
}
|
||||
|
||||
func remove(_ id: UUID) { connections.removeAll { $0.id == id } }
|
||||
|
||||
func move(from source: IndexSet, to destination: Int) {
|
||||
connections.move(fromOffsets: source, toOffset: destination)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(connections) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Network failures in words an operator can act on.
|
||||
enum NetworkProblem {
|
||||
static func describe(_ error: Error, url: URL?) -> String {
|
||||
let ns = error as NSError
|
||||
let place = Address.place(url)
|
||||
guard ns.domain == NSURLErrorDomain else { return ns.localizedDescription }
|
||||
switch ns.code {
|
||||
case NSURLErrorCannotConnectToHost:
|
||||
return "Nothing is answering at \(place). ProdDash may not be running there, or it may use another port."
|
||||
case NSURLErrorTimedOut:
|
||||
return "\(place) didn't answer in time."
|
||||
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
|
||||
return "\(url?.host ?? place) can't be found on the network."
|
||||
case NSURLErrorNotConnectedToInternet:
|
||||
return "This Mac isn't on a network — or macOS is keeping ProdDash Viewer off it. Check System Settings → Privacy & Security → Local Network."
|
||||
case NSURLErrorNetworkConnectionLost:
|
||||
return "The connection to \(place) dropped."
|
||||
case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted,
|
||||
NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateHasBadDate,
|
||||
NSURLErrorServerCertificateNotYetValid:
|
||||
return "\(place) offered a secure connection this Mac doesn't trust."
|
||||
default:
|
||||
return ns.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks an address whether ProdDash is there, for the connection sheet.
|
||||
enum Probe {
|
||||
|
||||
enum Answer: Equatable {
|
||||
case prodDash
|
||||
case somethingElse
|
||||
case noAnswer(String)
|
||||
}
|
||||
|
||||
private static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 4
|
||||
config.timeoutIntervalForResource = 6
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
/// `/api/modules` is the first thing every dashboard asks for, and it
|
||||
/// answers without a PIN — so `{"modules": …}` there means ProdDash.
|
||||
static func ask(_ url: URL) async -> Answer {
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return .somethingElse }
|
||||
parts.path = "/api/modules"
|
||||
parts.query = nil
|
||||
parts.fragment = nil
|
||||
guard let target = parts.url else { return .somethingElse }
|
||||
do {
|
||||
let (data, response) = try await session.data(from: target)
|
||||
let ok = (response as? HTTPURLResponse)?.statusCode == 200
|
||||
let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return ok && body?["modules"] != nil ? .prodDash : .somethingElse
|
||||
} catch {
|
||||
return .noAnswer(NetworkProblem.describe(error, url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
/**
|
||||
One installation's dashboard: its web view, and what shows instead while the
|
||||
installation can't be reached. Every page stays alive while the viewer runs,
|
||||
so switching tabs is instant and the dashboards behind the one on screen stay
|
||||
live.
|
||||
*/
|
||||
final class DashboardPage: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||
|
||||
final class Status: ObservableObject {
|
||||
enum Phase: Equatable {
|
||||
case connecting
|
||||
case showing
|
||||
case unreachable(String)
|
||||
}
|
||||
@Published var phase: Phase = .connecting
|
||||
@Published var name = ""
|
||||
@Published var address = ""
|
||||
}
|
||||
|
||||
private(set) var connection: Connection
|
||||
/// What the window shows: the web view, with the status view over it.
|
||||
let view = NSView()
|
||||
private(set) var webView: WKWebView
|
||||
let status = Status()
|
||||
/// Something the tab bar or the menus show has changed.
|
||||
var onChange: () -> Void = {}
|
||||
|
||||
private let window: () -> NSWindow?
|
||||
private var statusView: NSView!
|
||||
private var retryTimer: Timer?
|
||||
private var retryDelay: TimeInterval = 2
|
||||
private var revealTimer: Timer?
|
||||
private var lastAttempt: URL?
|
||||
|
||||
var isUnreachable: Bool {
|
||||
if case .unreachable = status.phase { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
init(connection: Connection, window: @escaping () -> NSWindow?) {
|
||||
self.connection = connection
|
||||
self.window = window
|
||||
self.webView = Self.makeWebView()
|
||||
super.init()
|
||||
status.name = connection.displayName
|
||||
status.address = connection.displayAddress
|
||||
install(webView)
|
||||
let overlay = NSHostingView(rootView: PageStatusView(status: status) { [weak self] in self?.retry() })
|
||||
overlay.sizingOptions = [] // sized by the window, never the other way round
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(overlay)
|
||||
pin(overlay)
|
||||
statusView = overlay
|
||||
}
|
||||
|
||||
private static func makeWebView() -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .default()
|
||||
// The dashboard's own full-screen button uses the Fullscreen API.
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
// An alert may need to sound on a dashboard nobody has touched.
|
||||
config.mediaTypesRequiringUserActionForPlayback = []
|
||||
config.applicationNameForUserAgent = "ProdDashViewer/\(AppDelegate.version)"
|
||||
let web = WKWebView(frame: .zero, configuration: config)
|
||||
web.allowsBackForwardNavigationGestures = true
|
||||
// Safari's Develop menu can inspect a dashboard running in here.
|
||||
if #available(macOS 13.3, *) { web.isInspectable = true }
|
||||
return web
|
||||
}
|
||||
|
||||
private func install(_ web: WKWebView) {
|
||||
web.navigationDelegate = self
|
||||
web.uiDelegate = self
|
||||
// Hidden until it has drawn something: no white flash on a dark booth
|
||||
// display while the first load is in flight.
|
||||
web.isHidden = true
|
||||
web.translatesAutoresizingMaskIntoConstraints = false
|
||||
if let statusView {
|
||||
view.addSubview(web, positioned: .below, relativeTo: statusView)
|
||||
} else {
|
||||
view.addSubview(web)
|
||||
}
|
||||
pin(web)
|
||||
}
|
||||
|
||||
private func pin(_ subview: NSView) {
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
subview.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func load() { load(connection.url) }
|
||||
|
||||
private func load(_ url: URL?) {
|
||||
guard let url else {
|
||||
return show(.unreachable("“\(connection.address)” isn't an address ProdDash Viewer can open. Change it in Settings."))
|
||||
}
|
||||
lastAttempt = url
|
||||
webView.load(URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 15))
|
||||
}
|
||||
|
||||
/// Reload, or — while the installation can't be reached — try again now.
|
||||
func reload() {
|
||||
if isUnreachable || webView.url == nil { return retry() }
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
func goBack() { webView.goBack() }
|
||||
func goForward() { webView.goForward() }
|
||||
|
||||
func retry() {
|
||||
retryTimer?.invalidate()
|
||||
load(lastAttempt ?? connection.url)
|
||||
}
|
||||
|
||||
/// An installation that isn't answering is usually one restarting — an
|
||||
/// update, a reboot — so keep asking, less often the longer it takes.
|
||||
private func scheduleRetry() {
|
||||
retryTimer?.invalidate()
|
||||
retryTimer = Timer.scheduledTimer(withTimeInterval: retryDelay, repeats: false) { [weak self] _ in
|
||||
self?.retry()
|
||||
}
|
||||
retryDelay = min(retryDelay * 2, 15)
|
||||
}
|
||||
|
||||
/// Settings changed this connection. A new address is a different
|
||||
/// installation, so it gets a fresh web view — Back can't wander into the
|
||||
/// old one.
|
||||
func update(_ new: Connection) {
|
||||
let moved = new.address != connection.address
|
||||
connection = new
|
||||
status.name = new.displayName
|
||||
status.address = new.displayAddress
|
||||
guard moved else { return onChange() }
|
||||
retire(webView)
|
||||
webView = Self.makeWebView()
|
||||
install(webView)
|
||||
retryTimer?.invalidate()
|
||||
revealTimer?.invalidate()
|
||||
retryDelay = 2
|
||||
show(.connecting)
|
||||
load()
|
||||
}
|
||||
|
||||
/// The connection was removed.
|
||||
func close() {
|
||||
retryTimer?.invalidate()
|
||||
revealTimer?.invalidate()
|
||||
retire(webView)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func retire(_ web: WKWebView) {
|
||||
web.stopLoading()
|
||||
web.navigationDelegate = nil
|
||||
web.uiDelegate = nil
|
||||
web.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func show(_ phase: Status.Phase) {
|
||||
status.phase = phase
|
||||
statusView.isHidden = phase == .showing
|
||||
onChange()
|
||||
}
|
||||
|
||||
private func isHome(_ url: URL) -> Bool {
|
||||
guard let home = connection.url.flatMap(Address.origin) else { return false }
|
||||
return Address.origin(url) == home
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard webView === self.webView else { return }
|
||||
reveal()
|
||||
}
|
||||
|
||||
/// Normally the load finishes within moments of this; but a tile's frame
|
||||
/// that never answers holds the load event back, and the dashboard
|
||||
/// shouldn't sit behind "Connecting…" waiting for it.
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
guard webView === self.webView, status.phase != .showing else { return }
|
||||
revealTimer?.invalidate()
|
||||
revealTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self, weak webView] _ in
|
||||
guard let self, let webView, webView === self.webView else { return }
|
||||
self.reveal()
|
||||
}
|
||||
}
|
||||
|
||||
private func reveal() {
|
||||
revealTimer?.invalidate()
|
||||
retryTimer?.invalidate()
|
||||
retryDelay = 2
|
||||
webView.isHidden = false
|
||||
if status.phase != .showing { show(.showing) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
failed(webView, error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
failed(webView, error)
|
||||
}
|
||||
|
||||
private func failed(_ web: WKWebView, _ error: Error) {
|
||||
guard web === webView else { return }
|
||||
let ns = error as NSError
|
||||
// Not failures: a load replaced by another, or one handed to the browser.
|
||||
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { return }
|
||||
if ns.domain == "WebKitErrorDomain" && (ns.code == 102 || ns.code == 204) { return }
|
||||
if let failing = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL, isHome(failing) {
|
||||
lastAttempt = failing
|
||||
}
|
||||
show(.unreachable(NetworkProblem.describe(error, url: lastAttempt)))
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
/// The page's process died (memory pressure, a WebKit crash). A booth
|
||||
/// display is never left blank: bring it back — after the usual retry
|
||||
/// delay, so a page that takes its process down as it loads can't spin.
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
guard webView === self.webView else { return }
|
||||
if let url = webView.url, isHome(url) { lastAttempt = url }
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
/// The viewer shows ProdDash and nothing else: the installation's own
|
||||
/// pages open here, any other site opens in the browser.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = action.request.url else { return decisionHandler(.allow) }
|
||||
// A tile may embed another site in a frame; only the page itself is policed.
|
||||
guard action.targetFrame?.isMainFrame ?? true else { return decisionHandler(.allow) }
|
||||
switch url.scheme?.lowercased() {
|
||||
case "http", "https":
|
||||
if !isHome(url) {
|
||||
let followed = action.targetFrame == nil || status.phase == .showing
|
||||
|| action.navigationType == .linkActivated || action.navigationType == .formSubmitted
|
||||
if followed {
|
||||
NSWorkspace.shared.open(url) // someone followed a link off ProdDash
|
||||
} else {
|
||||
redirected(to: url)
|
||||
}
|
||||
return decisionHandler(.cancel)
|
||||
}
|
||||
if action.targetFrame == nil {
|
||||
// target="_blank" or window.open on ProdDash's own page: one
|
||||
// window, so it opens here.
|
||||
let request = action.request
|
||||
DispatchQueue.main.async { webView.load(request) }
|
||||
return decisionHandler(.cancel)
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
case "about", "blob", "data":
|
||||
decisionHandler(.allow)
|
||||
case "file":
|
||||
// A file dropped somewhere the page didn't take it.
|
||||
decisionHandler(.cancel)
|
||||
default:
|
||||
NSWorkspace.shared.open(url) // mailto: and the like
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something the page can't show is a download: the browser does those.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
if response.isForMainFrame, !response.canShowMIMEType, let url = response.response.url {
|
||||
NSWorkspace.shared.open(url)
|
||||
return decisionHandler(.cancel)
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
/// The address itself sends the viewer somewhere else — a proxy, http
|
||||
/// moving to https. Handing that to the browser would leave the page
|
||||
/// stuck connecting, and do it again on every retry.
|
||||
private func redirected(to url: URL) {
|
||||
show(.unreachable("\(Address.place(connection.url)) sends ProdDash Viewer on to \(Address.originDisplay(url)). If that's where ProdDash answers, change the address in Settings to it."))
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
/// window.open(), which doesn't pass through decidePolicyFor: ProdDash's
|
||||
/// own pages open here, other sites in the browser. The viewer never
|
||||
/// opens a second window.
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
guard let url = action.request.url else { return nil }
|
||||
if isHome(url) {
|
||||
webView.load(action.request)
|
||||
} else if url.scheme == "http" || url.scheme == "https" {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - JavaScript dialogs and file pickers
|
||||
|
||||
// A WKWebView answers alert, confirm and prompt with "no" unless the app
|
||||
// shows them — and the admin page confirms before deleting a layout, Band
|
||||
// Lineup prompts for names.
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
let alert = dialog(message)
|
||||
alert.addButton(withTitle: "OK")
|
||||
present(alert) { _ in completionHandler() }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
let alert = dialog(message)
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
present(alert) { completionHandler($0 == .alertFirstButtonReturn) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String,
|
||||
defaultText: String?, initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping (String?) -> Void) {
|
||||
let alert = dialog(prompt)
|
||||
let field = NSTextField(string: defaultText ?? "")
|
||||
field.frame = NSRect(x: 0, y: 0, width: 280, height: 22)
|
||||
alert.accessoryView = field
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.window.initialFirstResponder = field
|
||||
present(alert) { completionHandler($0 == .alertFirstButtonReturn ? field.stringValue : nil) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = parameters.allowsDirectories
|
||||
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||
if let window = window(), window.attachedSheet == nil {
|
||||
panel.beginSheetModal(for: window) { completionHandler($0 == .OK ? panel.urls : nil) }
|
||||
} else {
|
||||
completionHandler(panel.runModal() == .OK ? panel.urls : nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Titled with the installation's name, so it's clear which one is asking.
|
||||
private func dialog(_ message: String) -> NSAlert {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = connection.displayName
|
||||
alert.informativeText = message
|
||||
return alert
|
||||
}
|
||||
|
||||
private func present(_ alert: NSAlert, then done: @escaping (NSApplication.ModalResponse) -> Void) {
|
||||
if let window = window(), window.attachedSheet == nil {
|
||||
alert.beginSheetModal(for: window, completionHandler: done)
|
||||
} else {
|
||||
done(alert.runModal())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Over the web view while there is nothing to show yet, or while the
|
||||
/// installation can't be reached.
|
||||
struct PageStatusView: View {
|
||||
|
||||
@ObservedObject var status: DashboardPage.Status
|
||||
let retry: () -> Void
|
||||
/// A fast load shouldn't flash a spinner.
|
||||
@State private var patient = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
switch status.phase {
|
||||
case .showing:
|
||||
EmptyView()
|
||||
case .connecting:
|
||||
if patient {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Connecting to \(status.name)…").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
case .unreachable(let why):
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "network.slash")
|
||||
.font(.system(size: 44, weight: .light))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
Text("Can’t Reach \(status.name)")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text(why)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 420)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(status.address)
|
||||
.font(.callout.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
.textSelection(.enabled)
|
||||
Button("Try Again", action: retry)
|
||||
.controlSize(.large)
|
||||
.padding(.top, 8)
|
||||
Text("ProdDash Viewer keeps trying by itself.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
patient = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
/// Help → ProdDash Viewer Help: the page that ships inside the app, so it
|
||||
/// opens with no network and always matches this version.
|
||||
final class HelpWindowController: NSWindowController, WKNavigationDelegate {
|
||||
|
||||
convenience init() {
|
||||
let web = WKWebView()
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 720),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = "ProdDash Viewer Help"
|
||||
window.minSize = NSSize(width: 420, height: 360)
|
||||
window.contentView = web
|
||||
window.tabbingMode = .disallowed
|
||||
window.isRestorable = false
|
||||
window.center()
|
||||
window.setFrameAutosaveName("ProdDashViewerHelp")
|
||||
self.init(window: window)
|
||||
web.navigationDelegate = self
|
||||
if let page = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "Help") {
|
||||
web.loadFileURL(page, allowingReadAccessTo: page.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
showWindow(nil)
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
/// The help page itself stays here; its links to the web open in the browser.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = action.request.url, !url.isFileURL, url.scheme != "about" else {
|
||||
return decisionHandler(.allow)
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Settings (⌘,): a standard toolbar-tabbed settings window with two panes,
|
||||
/// Connections and General. Every change applies as it's made.
|
||||
final class SettingsWindowController: NSWindowController {
|
||||
|
||||
private var model: SettingsModel!
|
||||
private var tabs: NSTabViewController!
|
||||
|
||||
convenience init() {
|
||||
let model = SettingsModel()
|
||||
let tabs = NSTabViewController()
|
||||
tabs.tabStyle = .toolbar
|
||||
tabs.addTabViewItem(Self.pane("Connections", symbol: "network",
|
||||
ConnectionsPane(store: .shared, model: model)))
|
||||
tabs.addTabViewItem(Self.pane("General", symbol: "gearshape",
|
||||
GeneralPane(settings: .shared)))
|
||||
let window = NSWindow(contentViewController: tabs)
|
||||
window.styleMask = [.titled, .closable]
|
||||
window.toolbarStyle = .preference
|
||||
window.isRestorable = false
|
||||
self.init(window: window)
|
||||
self.model = model
|
||||
self.tabs = tabs
|
||||
window.center()
|
||||
}
|
||||
|
||||
private static func pane<Content: View>(_ label: String, symbol: String, _ content: Content) -> NSTabViewItem {
|
||||
let controller = NSHostingController(rootView: content)
|
||||
controller.sizingOptions = .preferredContentSize
|
||||
controller.title = label // the window takes its title from the pane
|
||||
let item = NSTabViewItem(viewController: controller)
|
||||
item.label = label
|
||||
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
|
||||
return item
|
||||
}
|
||||
|
||||
/// Show Settings; `addingConnection` opens it on the add-a-connection sheet.
|
||||
func show(addingConnection: Bool = false) {
|
||||
if addingConnection {
|
||||
tabs.selectedTabViewItemIndex = 0
|
||||
model.pendingAdd = true
|
||||
}
|
||||
showWindow(nil)
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
final class SettingsModel: ObservableObject {
|
||||
/// Set to open the add sheet as soon as the Connections pane can.
|
||||
@Published var pendingAdd = false
|
||||
}
|
||||
|
||||
// MARK: - Connections
|
||||
|
||||
private struct ConnectionDraft: Identifiable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var address: String
|
||||
var isNew: Bool
|
||||
}
|
||||
|
||||
private struct ConnectionsPane: View {
|
||||
|
||||
@ObservedObject var store: ConnectionStore
|
||||
@ObservedObject var model: SettingsModel
|
||||
@State private var selection: UUID?
|
||||
@State private var editing: ConnectionDraft?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Each connection is a ProdDash installation on your network. With more than one, a tab bar at the top of the window switches between them — drag them into the order you want.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
List(selection: $selection) {
|
||||
ForEach(store.connections) { connection in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(connection.displayName)
|
||||
Text(connection.displayAddress)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
.tag(connection.id)
|
||||
}
|
||||
.onMove { store.move(from: $0, to: $1) }
|
||||
}
|
||||
.listStyle(.bordered(alternatesRowBackgrounds: true))
|
||||
.contextMenu(forSelectionType: UUID.self) { ids in
|
||||
Button("Edit…") { edit(ids.first) }
|
||||
.disabled(ids.count != 1)
|
||||
Button("Remove") {
|
||||
ids.forEach(store.remove)
|
||||
if let selected = selection, ids.contains(selected) { selection = nil }
|
||||
}
|
||||
} primaryAction: { ids in
|
||||
edit(ids.first)
|
||||
}
|
||||
.overlay {
|
||||
if store.connections.isEmpty {
|
||||
Text("No Connections").foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(height: 210)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Button(action: add) {
|
||||
Image(systemName: "plus").frame(width: 14)
|
||||
}
|
||||
.help("Add a connection")
|
||||
.accessibilityLabel("Add")
|
||||
Button(action: removeSelected) {
|
||||
Image(systemName: "minus").frame(width: 14)
|
||||
}
|
||||
.help("Remove the selected connection")
|
||||
.accessibilityLabel("Remove")
|
||||
.disabled(selection == nil)
|
||||
Spacer()
|
||||
Button("Edit…") { edit(selection) }
|
||||
.disabled(selection == nil)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
.sheet(item: $editing) { draft in
|
||||
ConnectionEditor(draft: draft, onSave: save)
|
||||
}
|
||||
.onReceive(model.$pendingAdd) { pending in
|
||||
guard pending else { return }
|
||||
DispatchQueue.main.async {
|
||||
model.pendingAdd = false
|
||||
add()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func add() {
|
||||
editing = ConnectionDraft(id: UUID(), name: "", address: "", isNew: true)
|
||||
}
|
||||
|
||||
private func edit(_ id: UUID?) {
|
||||
guard let id, let connection = store.connections.first(where: { $0.id == id }) else { return }
|
||||
editing = ConnectionDraft(id: id, name: connection.name, address: connection.displayAddress, isNew: false)
|
||||
}
|
||||
|
||||
private func removeSelected() {
|
||||
guard let id = selection else { return }
|
||||
store.remove(id)
|
||||
selection = nil
|
||||
}
|
||||
|
||||
private func save(_ draft: ConnectionDraft) {
|
||||
guard let url = Address.normalize(draft.address) else { return }
|
||||
let connection = Connection(id: draft.id,
|
||||
name: draft.name.trimmingCharacters(in: .whitespaces),
|
||||
address: url.absoluteString)
|
||||
if draft.isNew { store.add(connection) } else { store.update(connection) }
|
||||
selection = connection.id
|
||||
}
|
||||
}
|
||||
|
||||
/// The add / edit sheet. It asks the address whether ProdDash is there as
|
||||
/// you type, so a mistyped address shows up here rather than on the booth
|
||||
/// display — but saving never depends on it: the installation may simply be
|
||||
/// switched off right now.
|
||||
private struct ConnectionEditor: View {
|
||||
|
||||
@State var draft: ConnectionDraft
|
||||
let onSave: (ConnectionDraft) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private enum Check: Equatable { case idle, checking, answer(Probe.Answer) }
|
||||
@State private var check: Check = .idle
|
||||
|
||||
private var url: URL? { Address.normalize(draft.address) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text(draft.isNew ? "Add Connection" : "Edit Connection")
|
||||
.font(.headline)
|
||||
|
||||
Form {
|
||||
TextField("Name:", text: $draft.name, prompt: Text(url?.host ?? "AB Booth"))
|
||||
TextField("Address:", text: $draft.address, prompt: Text("10.3.11.152:24500"))
|
||||
LabeledContent {
|
||||
status
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(draft.isNew ? "Add" : "Save") {
|
||||
onSave(draft)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(url == nil)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 460)
|
||||
.task(id: draft.address) {
|
||||
guard let url else { return check = .idle }
|
||||
check = .checking
|
||||
try? await Task.sleep(for: .milliseconds(500)) // let the typing finish
|
||||
guard !Task.isCancelled else { return }
|
||||
let answer = await Probe.ask(url)
|
||||
guard !Task.isCancelled else { return }
|
||||
check = .answer(answer)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var status: some View {
|
||||
switch check {
|
||||
case .idle where url == nil:
|
||||
Text(draft.address.isEmpty
|
||||
? "The address you open ProdDash at in a browser. Without a port, ProdDash’s \(String(Address.defaultPort)) is used."
|
||||
: "That isn’t an address.")
|
||||
.foregroundStyle(.secondary)
|
||||
case .idle, .checking: // idle with an address: the check is about to start
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini)
|
||||
Text("Checking \(url.map(Address.display) ?? "")…").foregroundStyle(.secondary)
|
||||
}
|
||||
case .answer(.prodDash):
|
||||
Label("ProdDash is answering at \(url.map(Address.display) ?? "").", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .answer(.somethingElse):
|
||||
Label("Something answers at \(url.map(Address.display) ?? "") — but it isn’t ProdDash.", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
case .answer(.noAnswer(let why)):
|
||||
Label(why, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
private struct GeneralPane: View {
|
||||
|
||||
@ObservedObject var settings: ViewerSettings
|
||||
@State private var openAtLogin = LoginItem.isEnabled || LoginItem.needsApproval
|
||||
@State private var needsApproval = LoginItem.needsApproval
|
||||
@State private var loginProblem: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Open at login", isOn: Binding(
|
||||
get: { openAtLogin },
|
||||
set: { wanted in
|
||||
loginProblem = LoginItem.set(wanted)
|
||||
needsApproval = LoginItem.needsApproval
|
||||
openAtLogin = LoginItem.isEnabled || needsApproval
|
||||
}))
|
||||
if needsApproval {
|
||||
HStack {
|
||||
Text("Waiting for your approval in System Settings.")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Open Login Items…") { LoginItem.openSystemSettings() }
|
||||
}
|
||||
}
|
||||
if let loginProblem {
|
||||
Text(loginProblem).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Toggle(isOn: $settings.keepDisplayAwake) {
|
||||
Text("Keep the display awake")
|
||||
Text("While a dashboard is on screen, the display doesn’t dim or sleep.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.frame(width: 520, height: needsApproval || loginProblem != nil ? 200 : 152)
|
||||
.onAppear(perform: refreshLoginItem)
|
||||
// Approving it in System Settings happens elsewhere; catch up on return.
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in
|
||||
refreshLoginItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshLoginItem() {
|
||||
needsApproval = LoginItem.needsApproval
|
||||
openAtLogin = LoginItem.isEnabled || needsApproval
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// What the tab bar shows: one tab per connection, in Settings' order.
|
||||
final class TabBarModel: ObservableObject {
|
||||
|
||||
struct Tab: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var title: String
|
||||
var address: String
|
||||
var unreachable: Bool
|
||||
}
|
||||
|
||||
@Published var tabs: [Tab] = []
|
||||
@Published var selection: UUID?
|
||||
var onSelect: (UUID) -> Void = { _ in }
|
||||
}
|
||||
|
||||
/// Hosts the tabs. A click on a tab works even while another app is in
|
||||
/// front, rather than only bringing the viewer forward.
|
||||
final class TabBarHostingView: NSHostingView<TabBar> {
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
}
|
||||
|
||||
/// The slim bar of installation names. It sits in the window's title bar
|
||||
/// area, so it takes the title bar's material in light and dark alike.
|
||||
struct TabBar: View {
|
||||
|
||||
@ObservedObject var model: TabBarModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(model.tabs) { tab in
|
||||
TabButton(tab: tab, selected: tab.id == model.selection) { model.onSelect(tab.id) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TabButton: View {
|
||||
|
||||
let tab: TabBarModel.Tab
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 5) {
|
||||
if tab.unreachable {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text(tab.title)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? .primary : .secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, minHeight: 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.primary.opacity(selected ? 0.12 : hovering ? 0.06 : 0))
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false) // the keyboard belongs to the dashboard; ⌃Tab switches
|
||||
.onHover { hovering = $0 }
|
||||
.help(tab.unreachable ? "\(tab.address) — not answering" : tab.address)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
/// The viewer's own preferences. The connections themselves are in
|
||||
/// ConnectionStore.
|
||||
final class ViewerSettings: ObservableObject {
|
||||
|
||||
static let shared = ViewerSettings()
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
/// Keep the display on while a dashboard is on screen — a booth display
|
||||
/// that dims halfway through a service is the failure this is here for.
|
||||
@Published var keepDisplayAwake: Bool {
|
||||
didSet { defaults.set(keepDisplayAwake, forKey: "keepDisplayAwake") }
|
||||
}
|
||||
|
||||
private init() {
|
||||
defaults.register(defaults: ["keepDisplayAwake": true])
|
||||
keepDisplayAwake = defaults.bool(forKey: "keepDisplayAwake")
|
||||
}
|
||||
|
||||
/// The window was in full screen when the viewer last quit, so it opens in
|
||||
/// full screen again — a booth Mac that restarts comes back as it was left.
|
||||
var reopenInFullScreen: Bool {
|
||||
get { defaults.bool(forKey: "reopenInFullScreen") }
|
||||
set { defaults.set(newValue, forKey: "reopenInFullScreen") }
|
||||
}
|
||||
|
||||
/// The tab that was showing, so the viewer reopens on it.
|
||||
var selectedConnection: UUID? {
|
||||
get { defaults.string(forKey: "selectedConnection").flatMap(UUID.init(uuidString:)) }
|
||||
set { defaults.set(newValue?.uuidString, forKey: "selectedConnection") }
|
||||
}
|
||||
}
|
||||
|
||||
/// "Open at login", through the macOS 13 login-item API. The operator may
|
||||
/// still have to approve it in System Settings — hence `needsApproval`, which
|
||||
/// Settings shows rather than failing silently.
|
||||
enum LoginItem {
|
||||
|
||||
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
|
||||
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
|
||||
|
||||
/// Nil when it worked; otherwise what went wrong, for Settings to show.
|
||||
static func set(_ enabled: Bool) -> String? {
|
||||
do {
|
||||
if enabled {
|
||||
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
|
||||
} else {
|
||||
if SMAppService.mainApp.status != .notRegistered { try SMAppService.mainApp.unregister() }
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
static func openSystemSettings() {
|
||||
SMAppService.openSystemSettingsLoginItems()
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/**
|
||||
The viewer's one window: the selected installation's dashboard and — when
|
||||
there is more than one — a slim tab bar of their names.
|
||||
|
||||
In a window the bar floats over the top of the dashboard, just under the
|
||||
title bar: it comes out while the pointer is on the title bar and tucks away
|
||||
after. It is deliberately not part of the title bar — anything there makes
|
||||
WebKit push the whole page down to clear it, and a dashboard jumping every
|
||||
time the bar came and went would be worse than no bar at all.
|
||||
|
||||
In full screen the bar *is* part of the title bar (a titlebar accessory), so
|
||||
it comes down with the menu bar like any Mac app's toolbar; there the title
|
||||
bar lives in a window of its own and the page stays put.
|
||||
*/
|
||||
final class ViewerWindowController: NSWindowController, NSWindowDelegate, NSMenuItemValidation {
|
||||
|
||||
static let tabBarHeight: CGFloat = 32
|
||||
|
||||
/// The standard title bar's height: where the dashboard starts.
|
||||
private static let titlebarHeight: CGFloat = {
|
||||
let probe = NSRect(x: 0, y: 0, width: 100, height: 100)
|
||||
return NSWindow.frameRect(forContentRect: probe, styleMask: [.titled]).height - probe.height
|
||||
}()
|
||||
|
||||
/// Empty state's "Add Connection…".
|
||||
var onAddConnection: () -> Void = {}
|
||||
|
||||
private let store = ConnectionStore.shared
|
||||
private let settings = ViewerSettings.shared
|
||||
private var pages: [UUID: DashboardPage] = [:]
|
||||
private var order: [UUID] = []
|
||||
private var selection: UUID?
|
||||
|
||||
private let content = ViewerContentView()
|
||||
private let pageArea = NSView()
|
||||
private var pageAreaTop: NSLayoutConstraint!
|
||||
private var emptyState: NSView!
|
||||
|
||||
private let tabModel = TabBarModel()
|
||||
private var tabOverlay: TabBarOverlay!
|
||||
private var tabOverlayTop: NSLayoutConstraint!
|
||||
private let tabAccessory = NSTitlebarAccessoryViewController()
|
||||
private var accessoryAttached = false
|
||||
private var tabBarShown = false
|
||||
private var hideTimer: Timer?
|
||||
private var inFullScreen = false
|
||||
private var leaving = false
|
||||
|
||||
private var awakeActivity: NSObjectProtocol?
|
||||
private var keyMonitor: Any?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var currentPage: DashboardPage? { selection.flatMap { pages[$0] } }
|
||||
private var hasTabs: Bool { order.count > 1 }
|
||||
|
||||
convenience init() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = "ProdDash Viewer"
|
||||
window.minSize = NSSize(width: 640, height: 420)
|
||||
window.tabbingMode = .disallowed // the tab bar here is ProdDash's, not AppKit's
|
||||
window.collectionBehavior.insert(.fullScreenPrimary)
|
||||
window.isRestorable = false // frame and full screen are kept below instead
|
||||
window.center()
|
||||
window.setFrameAutosaveName("ProdDashViewerWindow")
|
||||
self.init(window: window)
|
||||
window.delegate = self
|
||||
build(in: window)
|
||||
}
|
||||
|
||||
private func build(in window: NSWindow) {
|
||||
// The content runs under the title bar so the pointer can be watched
|
||||
// there; the dashboard itself starts below it.
|
||||
window.contentView = content
|
||||
|
||||
pageArea.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(pageArea)
|
||||
pageAreaTop = pageArea.topAnchor.constraint(equalTo: content.topAnchor, constant: Self.titlebarHeight)
|
||||
|
||||
let empty = NSHostingView(rootView: EmptyStateView { [weak self] in self?.onAddConnection() })
|
||||
empty.sizingOptions = [] // sized by the window, never the other way round
|
||||
empty.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(empty)
|
||||
emptyState = empty
|
||||
|
||||
tabOverlay = TabBarOverlay(model: tabModel)
|
||||
tabOverlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
tabOverlay.isHidden = true
|
||||
tabOverlay.alphaValue = 0
|
||||
content.addSubview(tabOverlay)
|
||||
tabOverlayTop = tabOverlay.topAnchor.constraint(equalTo: content.topAnchor, constant: hiddenOverlayTop)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
pageAreaTop,
|
||||
pageArea.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
pageArea.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
pageArea.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
||||
empty.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||||
empty.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||||
empty.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||||
empty.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||||
tabOverlayTop,
|
||||
tabOverlay.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
tabOverlay.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
tabOverlay.heightAnchor.constraint(equalToConstant: Self.tabBarHeight),
|
||||
])
|
||||
|
||||
let bar = TabBarHostingView(rootView: TabBar(model: tabModel))
|
||||
bar.sizingOptions = []
|
||||
bar.frame = NSRect(x: 0, y: 0, width: 800, height: Self.tabBarHeight)
|
||||
tabAccessory.view = bar
|
||||
tabAccessory.layoutAttribute = .bottom
|
||||
tabAccessory.fullScreenMinHeight = 0 // out of sight until the menu bar comes down
|
||||
tabModel.onSelect = { [weak self] id in self?.select(id) }
|
||||
|
||||
content.topZoneHeight = { [weak self] in self?.revealZoneHeight ?? 0 }
|
||||
content.onPointerInTopZone = { [weak self] inside in self?.pointerInTopZone(inside) }
|
||||
|
||||
selection = settings.selectedConnection
|
||||
// @Published sends before it stores, so use the value it sends.
|
||||
store.$connections
|
||||
.sink { [weak self] connections in self?.sync(connections) }
|
||||
.store(in: &cancellables)
|
||||
settings.$keepDisplayAwake
|
||||
.sink { [weak self] keep in self?.updateDisplayAwake(keep) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
installKeyboardSwitching()
|
||||
}
|
||||
|
||||
// MARK: - Connections and tabs
|
||||
|
||||
private func sync(_ connections: [Connection]) {
|
||||
let ids = Set(connections.map(\.id))
|
||||
let hadTabs = hasTabs
|
||||
for (id, page) in pages where !ids.contains(id) {
|
||||
page.close()
|
||||
pages[id] = nil
|
||||
}
|
||||
for connection in connections {
|
||||
if let page = pages[connection.id] {
|
||||
page.update(connection)
|
||||
} else {
|
||||
let page = DashboardPage(connection: connection) { [weak self] in self?.window }
|
||||
page.onChange = { [weak self, weak page] in
|
||||
if let page { self?.pageChanged(page) }
|
||||
}
|
||||
pages[connection.id] = page
|
||||
// A dashboard behind the one on screen still lays itself out
|
||||
// at the window's size, not at nothing.
|
||||
page.view.frame = NSRect(x: 0, y: 0, width: content.bounds.width,
|
||||
height: max(content.bounds.height - pageAreaTop.constant, 0))
|
||||
page.view.layoutSubtreeIfNeeded()
|
||||
page.load()
|
||||
}
|
||||
}
|
||||
order = connections.map(\.id)
|
||||
if selection.map(ids.contains) != true { selection = order.first }
|
||||
showSelection()
|
||||
refreshTabs()
|
||||
placeTabBar()
|
||||
if hasTabs && !hadTabs { flashTabBar(for: 3) } // out long enough to be noticed
|
||||
updateDisplayAwake(settings.keepDisplayAwake)
|
||||
}
|
||||
|
||||
private func pageChanged(_ page: DashboardPage) {
|
||||
refreshTabs()
|
||||
if page === currentPage {
|
||||
updateTitle()
|
||||
// A new address means a new web view: put it on screen.
|
||||
if page.view.superview !== pageArea { showSelection() }
|
||||
// The web view can't take the keyboard until it's shown something.
|
||||
if page.status.phase == .showing, (window?.firstResponder as? NSView)?.isDescendant(of: page.webView) != true {
|
||||
window?.makeFirstResponder(page.webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showSelection() {
|
||||
let page = currentPage
|
||||
for view in pageArea.subviews where view !== page?.view { view.removeFromSuperview() }
|
||||
if let page, page.view.superview !== pageArea {
|
||||
page.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
pageArea.addSubview(page.view)
|
||||
NSLayoutConstraint.activate([
|
||||
page.view.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||||
page.view.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||||
page.view.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||||
page.view.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||||
])
|
||||
}
|
||||
emptyState.isHidden = page != nil
|
||||
if let page { window?.makeFirstResponder(page.webView) }
|
||||
settings.selectedConnection = selection
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
private func updateTitle() {
|
||||
window?.title = currentPage?.connection.displayName ?? "ProdDash Viewer"
|
||||
}
|
||||
|
||||
private func refreshTabs() {
|
||||
tabModel.tabs = order.compactMap { id in
|
||||
pages[id].map {
|
||||
TabBarModel.Tab(id: id, title: $0.connection.displayName,
|
||||
address: $0.connection.displayAddress, unreachable: $0.isUnreachable)
|
||||
}
|
||||
}
|
||||
tabModel.selection = selection
|
||||
}
|
||||
|
||||
func select(_ id: UUID) {
|
||||
guard pages[id] != nil else { return }
|
||||
if id != selection {
|
||||
selection = id
|
||||
showSelection()
|
||||
refreshTabs()
|
||||
}
|
||||
flashTabBar(for: 2)
|
||||
}
|
||||
|
||||
private func select(index: Int) {
|
||||
guard order.indices.contains(index) else { return }
|
||||
select(order[index])
|
||||
}
|
||||
|
||||
private func step(_ delta: Int) {
|
||||
guard !order.isEmpty else { return }
|
||||
let index = selection.flatMap { order.firstIndex(of: $0) } ?? 0
|
||||
select(index: (index + delta + order.count) % order.count)
|
||||
}
|
||||
|
||||
/// ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 (⌘9 the last), as in Safari. Not menu items:
|
||||
/// the menus stay as short as the viewer is.
|
||||
private func installKeyboardSwitching() {
|
||||
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self, event.window === self.window, self.hasTabs else { return event }
|
||||
// Only the keys that are pressed together — Caps Lock doesn't count.
|
||||
let mods = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||
if event.keyCode == 48, mods.subtracting(.shift) == .control { // Tab
|
||||
self.step(mods.contains(.shift) ? -1 : 1)
|
||||
return nil
|
||||
}
|
||||
if mods == .command, let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...9).contains(digit) {
|
||||
self.select(index: digit == 9 ? self.order.count - 1 : digit - 1)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The tab bar coming and going
|
||||
|
||||
/// Overlay in a window, titlebar accessory in full screen — and neither
|
||||
/// with only one connection. The accessory stays attached whenever there
|
||||
/// are tabs, so it is already in place when AppKit builds the full-screen
|
||||
/// title bar; in a window it is hidden, and hidden it takes no room, so
|
||||
/// the page stays put.
|
||||
private func placeTabBar() {
|
||||
tabAccessory.isHidden = !inFullScreen
|
||||
if hasTabs != accessoryAttached {
|
||||
accessoryAttached = hasTabs
|
||||
if hasTabs {
|
||||
window?.addTitlebarAccessoryViewController(tabAccessory)
|
||||
} else {
|
||||
tabAccessory.removeFromParent()
|
||||
}
|
||||
}
|
||||
if !hasTabs {
|
||||
hideTimer?.invalidate()
|
||||
setTabBar(shown: false, animated: false)
|
||||
}
|
||||
content.updateTrackingAreas()
|
||||
}
|
||||
|
||||
/// Under the title bar in a window; the top of the screen in full screen,
|
||||
/// where it only flashes up after a switch.
|
||||
private var shownOverlayTop: CGFloat { inFullScreen ? 0 : Self.titlebarHeight }
|
||||
/// Slid up by a few points while hidden, so it slides down as it fades in.
|
||||
private var hiddenOverlayTop: CGFloat { shownOverlayTop - 8 }
|
||||
|
||||
/// The strip along the top that brings the bar out: the title bar, a
|
||||
/// sliver of the dashboard below it, and the bar itself while it's out.
|
||||
private var revealZoneHeight: CGFloat {
|
||||
guard hasTabs, !inFullScreen else { return 0 }
|
||||
return Self.titlebarHeight + (tabBarShown ? Self.tabBarHeight : 4)
|
||||
}
|
||||
|
||||
private func pointerInTopZone(_ inside: Bool) {
|
||||
if inside {
|
||||
hideTimer?.invalidate()
|
||||
setTabBar(shown: true)
|
||||
} else {
|
||||
scheduleHide(after: 0.8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the bar out for a moment — at launch, and after switching, so
|
||||
/// it's clear what changed. In full screen too, where nothing else says
|
||||
/// which installation is on screen.
|
||||
private func flashTabBar(for seconds: TimeInterval) {
|
||||
guard hasTabs else { return }
|
||||
setTabBar(shown: true)
|
||||
if !content.pointerInTopZone { scheduleHide(after: seconds) }
|
||||
}
|
||||
|
||||
private func scheduleHide(after delay: TimeInterval) {
|
||||
hideTimer?.invalidate()
|
||||
hideTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
|
||||
guard let self, !self.content.pointerInTopZone else { return }
|
||||
self.setTabBar(shown: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func setTabBar(shown: Bool, animated: Bool = true) {
|
||||
let shown = shown && hasTabs
|
||||
guard shown != tabBarShown else { return }
|
||||
tabBarShown = shown
|
||||
if shown { tabOverlay.isHidden = false }
|
||||
content.layoutSubtreeIfNeeded()
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = animated ? 0.18 : 0
|
||||
context.allowsImplicitAnimation = true
|
||||
tabOverlay.animator().alphaValue = shown ? 1 : 0
|
||||
tabOverlayTop.animator().constant = shown ? shownOverlayTop : hiddenOverlayTop
|
||||
content.layoutSubtreeIfNeeded()
|
||||
}, completionHandler: { [weak self] in
|
||||
guard let self, !self.tabBarShown else { return }
|
||||
self.tabOverlay.isHidden = true
|
||||
})
|
||||
content.updateTrackingAreas()
|
||||
}
|
||||
|
||||
// MARK: - Full screen
|
||||
|
||||
func windowWillEnterFullScreen(_ notification: Notification) {
|
||||
settleOverlay()
|
||||
inFullScreen = true
|
||||
pageAreaTop.constant = 0
|
||||
placeTabBar()
|
||||
tabOverlayTop.constant = hiddenOverlayTop
|
||||
}
|
||||
|
||||
func windowDidEnterFullScreen(_ notification: Notification) {
|
||||
if !leaving { settings.reopenInFullScreen = true }
|
||||
}
|
||||
|
||||
/// The accessory hides before the window is a window again — showing in
|
||||
/// a window's title bar, it would push the dashboard down.
|
||||
func windowWillExitFullScreen(_ notification: Notification) {
|
||||
settleOverlay()
|
||||
inFullScreen = false
|
||||
pageAreaTop.constant = Self.titlebarHeight
|
||||
placeTabBar()
|
||||
tabOverlayTop.constant = hiddenOverlayTop
|
||||
}
|
||||
|
||||
/// No overlay riding through the full-screen animation.
|
||||
private func settleOverlay() {
|
||||
hideTimer?.invalidate()
|
||||
setTabBar(shown: false, animated: false)
|
||||
}
|
||||
|
||||
func windowDidExitFullScreen(_ notification: Notification) {
|
||||
if !leaving { settings.reopenInFullScreen = false }
|
||||
content.updateTrackingAreas()
|
||||
}
|
||||
|
||||
/// Called on the way out, before AppKit takes the window out of full
|
||||
/// screen on its own account.
|
||||
func rememberFullScreen() {
|
||||
guard !leaving, let window else { return }
|
||||
leaving = true
|
||||
settings.reopenInFullScreen = window.styleMask.contains(.fullScreen)
|
||||
}
|
||||
|
||||
// MARK: - Window
|
||||
|
||||
/// Before AppKit takes a full-screen window out of full screen to close it.
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
rememberFullScreen()
|
||||
return true
|
||||
}
|
||||
|
||||
/// One window is the whole app: closing it quits, as in System Settings.
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
rememberFullScreen()
|
||||
DispatchQueue.main.async { NSApp.terminate(nil) }
|
||||
}
|
||||
|
||||
func windowDidChangeOcclusionState(_ notification: Notification) {
|
||||
updateDisplayAwake(settings.keepDisplayAwake)
|
||||
}
|
||||
|
||||
/// Hold the display awake while a dashboard is actually on screen — not
|
||||
/// while the window is minimised, hidden or covered.
|
||||
private func updateDisplayAwake(_ keep: Bool) {
|
||||
let visible = window?.occlusionState.contains(.visible) == true
|
||||
let wanted = keep && visible && !order.isEmpty
|
||||
if wanted, awakeActivity == nil {
|
||||
awakeActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.idleDisplaySleepDisabled], reason: "Showing a ProdDash dashboard")
|
||||
} else if !wanted, let activity = awakeActivity {
|
||||
ProcessInfo.processInfo.endActivity(activity)
|
||||
awakeActivity = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View menu
|
||||
|
||||
@objc func goBackInPage(_ sender: Any?) { currentPage?.goBack() }
|
||||
@objc func goForwardInPage(_ sender: Any?) { currentPage?.goForward() }
|
||||
@objc func reloadPage(_ sender: Any?) { currentPage?.reload() }
|
||||
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
switch item.action {
|
||||
case #selector(goBackInPage(_:)): return currentPage?.webView.canGoBack == true
|
||||
case #selector(goForwardInPage(_:)): return currentPage?.webView.canGoForward == true
|
||||
case #selector(reloadPage(_:)): return currentPage != nil
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab bar as it shows in a window: the title bar's material, the tabs,
|
||||
/// and a hairline under them, floating over the top of the dashboard.
|
||||
final class TabBarOverlay: NSView {
|
||||
|
||||
init(model: TabBarModel) {
|
||||
super.init(frame: .zero)
|
||||
let material = NSVisualEffectView()
|
||||
material.material = .titlebar
|
||||
material.blendingMode = .withinWindow
|
||||
material.state = .followsWindowActiveState
|
||||
let tabs = TabBarHostingView(rootView: TabBar(model: model))
|
||||
tabs.sizingOptions = []
|
||||
let rule = Hairline()
|
||||
for view in [material, tabs, rule] {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(view)
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
material.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
material.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
material.topAnchor.constraint(equalTo: topAnchor),
|
||||
material.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
tabs.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
tabs.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
tabs.topAnchor.constraint(equalTo: topAnchor),
|
||||
tabs.bottomAnchor.constraint(equalTo: rule.topAnchor),
|
||||
rule.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
rule.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
rule.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
rule.heightAnchor.constraint(equalToConstant: 1),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("not used") }
|
||||
|
||||
/// The title bar's separator line. (An NSBox separator would do, but one
|
||||
/// laid out from a zero frame decides it's vertical and pins the window
|
||||
/// to a point wide.)
|
||||
private final class Hairline: NSView {
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
NSColor.separatorColor.setFill()
|
||||
bounds.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's content view. It watches the strip along the top of the
|
||||
/// window — which, with the content drawn under the title bar, includes the
|
||||
/// title bar — and says when the pointer comes and goes. Tracking stays on
|
||||
/// while another app is in front, so the bar answers the pointer even then.
|
||||
final class ViewerContentView: NSView {
|
||||
|
||||
var topZoneHeight: () -> CGFloat = { 0 }
|
||||
var onPointerInTopZone: (Bool) -> Void = { _ in }
|
||||
private var zone: NSTrackingArea?
|
||||
|
||||
private var zoneRect: NSRect {
|
||||
let height = min(topZoneHeight(), bounds.height)
|
||||
return NSRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
|
||||
}
|
||||
|
||||
var pointerInTopZone: Bool {
|
||||
guard let window else { return false }
|
||||
let rect = zoneRect
|
||||
return rect.height > 0 && rect.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let zone { removeTrackingArea(zone) }
|
||||
zone = nil
|
||||
let rect = zoneRect
|
||||
guard rect.height > 0 else { return }
|
||||
let area = NSTrackingArea(rect: rect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
|
||||
addTrackingArea(area)
|
||||
zone = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
if event.trackingArea === zone { onPointerInTopZone(true) }
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
if event.trackingArea === zone { onPointerInTopZone(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The window before any connection exists.
|
||||
struct EmptyStateView: View {
|
||||
|
||||
let add: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 96, height: 96)
|
||||
Text("No ProdDash Connections")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Add the address of a ProdDash installation and its dashboard opens here. Add more than one to switch between them.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 380)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Add Connection…", action: add)
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// ProdDash Viewer — a window onto ProdDash, and nothing else.
|
||||
let delegate = AppDelegate()
|
||||
let application = NSApplication.shared
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.regular)
|
||||
application.run()
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/bin/zsh
|
||||
# Build ProdDash Viewer.app — a window onto ProdDash installations, for Apple
|
||||
# silicon Macs. Native Swift, no dependencies.
|
||||
#
|
||||
# ./build.sh build/ProdDash Viewer.app
|
||||
# ./build.sh --dmg …and build/ProdDash-Viewer-<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)
|
||||
#
|
||||
# Signing works as ProdDash's own app does (launcher/macos/README.md): a
|
||||
# "Developer ID Application" certificate when one is installed (override with
|
||||
# CODESIGN_IDENTITY), ad-hoc when none is — fine on your own Macs, but another
|
||||
# Mac asks before it opens an ad-hoc app.
|
||||
#
|
||||
# Needs Xcode (xcode-select pointing at it): with the command line tools
|
||||
# alone, the macOS 27 SDK can't expand SwiftUI's @State macro.
|
||||
set -e -u
|
||||
SCRIPT="${0:A}"
|
||||
cd "${SCRIPT:h}"
|
||||
|
||||
APP_NAME="ProdDash Viewer"
|
||||
EXECUTABLE="ProdDashViewer"
|
||||
BUNDLE_ID="org.waterschurch.proddash.viewer"
|
||||
VERSION="1.0.0"
|
||||
ARCH="arm64" # Apple silicon
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
|
||||
OUT_DIR="build"
|
||||
APP="$OUT_DIR/$APP_NAME.app"
|
||||
CONTENTS="$APP/Contents"
|
||||
|
||||
INSTALL=0
|
||||
RUN=0
|
||||
MAKE_DMG=0
|
||||
NOTARIZE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--install) INSTALL=1 ;;
|
||||
--run) RUN=1 ;;
|
||||
--dmg) MAKE_DMG=1 ;;
|
||||
--notarize) NOTARIZE=1; MAKE_DMG=1 ;;
|
||||
-h|--help) sed -n '2,18p' "$SCRIPT"; 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 Xcode, then: sudo xcode-select -s /Applications/Xcode.app" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "$APP_NAME $VERSION — building for $ARCH"
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||
|
||||
echo " compiling"
|
||||
swiftc -O -whole-module-optimization \
|
||||
-target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \
|
||||
-module-name ProdDashViewer \
|
||||
-o "$CONTENTS/MacOS/$EXECUTABLE" \
|
||||
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
|
||||
printf 'APPL????' > "$CONTENTS/PkgInfo"
|
||||
|
||||
cp -R Resources/Help "$CONTENTS/Resources/Help"
|
||||
|
||||
# Resources/AppIcon.png is drawn by Resources/make-icon.swift — ProdDash's
|
||||
# tile mark on a display, so the Viewer and ProdDash itself tell apart.
|
||||
echo " building the icon"
|
||||
ICONSET="$OUT_DIR/AppIcon.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z $size $size Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
double=$((size * 2))
|
||||
sips -z $double $double Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns"
|
||||
rm -rf "$ICONSET"
|
||||
find "$APP" -name '.DS_Store' -delete
|
||||
|
||||
# ── 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
|
||||
codesign "${SIGN[@]}" --identifier "$BUNDLE_ID" "$APP"
|
||||
codesign --verify --deep --strict "$APP" # stops the build if it doesn't
|
||||
echo " signature verifies"
|
||||
|
||||
echo "built $APP ($(du -sh "$APP" | awk '{print $1}'))"
|
||||
|
||||
# ── disk image ───────────────────────────────────────────────────────────
|
||||
if [[ $MAKE_DMG -eq 1 ]]; then
|
||||
DMG="$OUT_DIR/ProdDash-Viewer-$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"
|
||||
if pgrep -x "$EXECUTABLE" >/dev/null; then
|
||||
osascript -e "tell application id \"$BUNDLE_ID\" to quit" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
fi
|
||||
rm -rf "$DEST"
|
||||
cp -R "$APP" "$DEST"
|
||||
echo "installed $DEST"
|
||||
APP="$DEST"
|
||||
fi
|
||||
|
||||
[[ $RUN -eq 1 ]] && open "$APP"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user