Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8c0ee0c3 | ||
|
|
c043834d1e | ||
|
|
1fd7dcdf1e | ||
|
|
e59629694e | ||
|
|
6da6f4c31e | ||
|
|
8e4a0e849e | ||
|
|
df2f0494f8 | ||
|
|
0548cb54fa | ||
|
|
c1152b4ab3 | ||
|
|
a692b6a9e2 | ||
|
|
e670e1ee72 | ||
|
|
4b0f161c93 | ||
|
|
dafb48d9ed |
@@ -13,3 +13,6 @@ launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
launcher/windows/build/
|
||||
launcher/windows/.cache/
|
||||
|
||||
# the Viewer's build output
|
||||
viewer/macos/build/
|
||||
|
||||
@@ -4,10 +4,13 @@ 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), and a native macOS menu-bar launcher in
|
||||
none and installs them from the admin page's catalog), a native macOS menu-bar launcher in
|
||||
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||
the server and its own Node runtime, supervises them, and owns the macOS
|
||||
permissions modules declare; see its README). Runtime state (module
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -521,11 +521,28 @@ 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 — drag a photo onto a slot, drag between slots
|
||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
||||
drops each into the slot matching their position, filling only slots nobody
|
||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
||||
title bar opens that library — the spots pinned on top, the photos (with a
|
||||
name search) scrolling below — drag a photo onto a slot (mouse, or press and
|
||||
hold on a touchscreen), drag between slots to swap, rename or clear.
|
||||
Dropping a photo onto the person already in a spot makes it *their* photo
|
||||
for that instrument (a nickname like "Rose" for Rosaliz counts): they keep
|
||||
their Planning Center name and spot, and every later Sync uses it whenever
|
||||
they are on that instrument.
|
||||
Planning Center is optional auto-fill on top, not the source of truth:
|
||||
**Sync** reads the upcoming plan's team members and places each one. A
|
||||
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
|
||||
that slot — this is how the leader and vocalists, whose position is just
|
||||
"Vocalist", land on the right mic — and everyone else goes to the slot
|
||||
matching their position, one slot per person. Photos come from the library
|
||||
whenever it has one of them, and Planning Center's headshot only when it
|
||||
has none. Someone who plays more than one thing can have a photo per
|
||||
instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`;
|
||||
Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and
|
||||
a plain `DJ.jpg` for everything else: Sync uses the one for the spot they
|
||||
are in that week. A photo dropped onto someone is remembered for that
|
||||
instrument only. Anything placed or moved by
|
||||
hand for that plan stays put through later syncs; last week's people are
|
||||
replaced.
|
||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||
@@ -601,6 +618,19 @@ sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## 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:
|
||||
@@ -631,6 +661,9 @@ 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
|
||||
|
||||
+144
-56
@@ -1,5 +1,5 @@
|
||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
|
||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||
// (Wed→Tue) rains confetti.
|
||||
@@ -14,26 +14,108 @@ export default function create({ root, moduleApi }) {
|
||||
let selectedLibId = ''; // click-to-place fallback for touch
|
||||
const canvases = new Set();
|
||||
let raf = null;
|
||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||
let dragScrolling = false;
|
||||
let dragY = 0;
|
||||
let dragBody = null;
|
||||
let dragRaf = null;
|
||||
function startDragAutoScroll() {
|
||||
if (dragRaf) return;
|
||||
const tick = () => {
|
||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||
const r = dragBody.getBoundingClientRect();
|
||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||
let dy = 0;
|
||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||
if (dy) dragBody.scrollTop += dy;
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
|
||||
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
|
||||
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
|
||||
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
|
||||
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
|
||||
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
|
||||
* edges. Board updates that arrive during a drag wait until it ends. */
|
||||
const DRAG_MOVE_PX = 6;
|
||||
const TOUCH_HOLD_MS = 220;
|
||||
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
|
||||
let pending = null; // a pointerdown that may become a drag
|
||||
let renderHeld = false; // a state update arrived mid-drag; render when it ends
|
||||
|
||||
function slotUnder(x, y) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
|
||||
return cell && root.contains(cell) ? cell : null;
|
||||
}
|
||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||
function beginDrag(p) {
|
||||
const ghost = el('div', 'bl-drag-ghost');
|
||||
ghost.dataset.module = 'band-lineup'; // lives on <body> (above every tile), so it carries its own scope
|
||||
const src = p.source.querySelector('img');
|
||||
if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); }
|
||||
else ghost.textContent = p.label || '';
|
||||
document.body.appendChild(ghost);
|
||||
drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source };
|
||||
p.source.classList.add('bl-dragging');
|
||||
root.classList.add('bl-is-dragging');
|
||||
moveDrag(p.x, p.y);
|
||||
const tick = () => {
|
||||
if (!drag) return;
|
||||
// Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
|
||||
const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
|
||||
if (b && b.scrollHeight > b.clientHeight) {
|
||||
const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
|
||||
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
|
||||
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
|
||||
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
|
||||
}
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
function moveDrag(x, y) {
|
||||
drag.x = x; drag.y = y;
|
||||
drag.ghost.style.transform = `translate(${x}px, ${y}px)`;
|
||||
const cell = slotUnder(x, y);
|
||||
if (cell !== drag.over) {
|
||||
if (drag.over) drag.over.classList.remove('bl-drop-over');
|
||||
drag.over = cell;
|
||||
if (cell) cell.classList.add('bl-drop-over');
|
||||
}
|
||||
}
|
||||
function endDrag(drop) {
|
||||
const d = drag; drag = null;
|
||||
if (!d) return;
|
||||
if (d.raf) cancelAnimationFrame(d.raf);
|
||||
d.ghost.remove();
|
||||
d.source.classList.remove('bl-dragging');
|
||||
root.classList.remove('bl-is-dragging');
|
||||
if (d.over) d.over.classList.remove('bl-drop-over');
|
||||
if (drop && d.over) {
|
||||
const to = d.over.dataset.index;
|
||||
if (d.kind === 'lib') assign(to, d.id);
|
||||
else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' });
|
||||
}
|
||||
if (renderHeld) { renderHeld = false; render(); }
|
||||
}
|
||||
function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; }
|
||||
// Make `node` draggable. `kind`/`id` say what is being dragged.
|
||||
function makeDraggable(node, kind, id, body, label) {
|
||||
node.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || drag) return;
|
||||
if (e.target.closest('button')) return; // name / clear / delete buttons keep working
|
||||
cancelPending();
|
||||
pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null };
|
||||
if (pending.touch) {
|
||||
const p = pending;
|
||||
p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Document-level move/up so the drag survives leaving the card it started on.
|
||||
function onPointerMove(e) {
|
||||
if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; }
|
||||
if (!pending || e.pointerId !== pending.pointerId) return;
|
||||
const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy);
|
||||
if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll
|
||||
if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); }
|
||||
}
|
||||
function onPointerUp(e) {
|
||||
if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); }
|
||||
cancelPending();
|
||||
}
|
||||
let suppressClick = false; // the click that follows a drop must not also select / place
|
||||
// While a finger drag is live, stop the page from scrolling under it.
|
||||
function onTouchMove(e) { if (drag) e.preventDefault(); }
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.addEventListener('pointercancel', onPointerUp);
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
function stopDragAutoScroll() { cancelPending(); endDrag(false); }
|
||||
|
||||
/* ---------- confetti (always animates) ---------- */
|
||||
function makeParticle(w, h, intensity, seedY) {
|
||||
@@ -79,11 +161,12 @@ export default function create({ root, moduleApi }) {
|
||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||
|
||||
function render() {
|
||||
if (drag) { renderHeld = true; return; }
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
root.innerHTML = '';
|
||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||
if (editing) { renderEditor(); return; }
|
||||
if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
|
||||
root.innerHTML = '';
|
||||
|
||||
const board = el('div', 'bl-board');
|
||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||
@@ -158,13 +241,16 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
let libFilter = ''; // the photo search box, kept across re-renders
|
||||
function renderEditor() {
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
||||
// board on top and the picture picker (library) below it.
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
|
||||
// that scroll on their own: the spots pinned on top, the photos below — so a photo found far
|
||||
// down the list never has to be carried back up to its spot. Both panes keep their scroll
|
||||
// position (and the search box its focus) when a board update redraws the editor.
|
||||
const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
|
||||
const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
|
||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||
const body = el('div', 'bl-editor-body');
|
||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||
const body = el('div', 'bl-editor-body bl-editor-split');
|
||||
|
||||
// Slots (drop targets)
|
||||
const boardEl = el('div', 'bl-edit-board');
|
||||
@@ -176,14 +262,7 @@ export default function create({ root, moduleApi }) {
|
||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||
cell.appendChild(photo);
|
||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||
if (slot.person) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||
}
|
||||
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
|
||||
const nameRow = el('div', 'bl-edit-namerow');
|
||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||
@@ -191,26 +270,23 @@ export default function create({ root, moduleApi }) {
|
||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||
cell.appendChild(nameRow);
|
||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||
// drop
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||
const data = e.dataTransfer.getData('text/plain') || '';
|
||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||
});
|
||||
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
body.appendChild(boardEl);
|
||||
const boardPane = el('div', 'bl-pane bl-board-pane');
|
||||
boardPane.appendChild(boardEl);
|
||||
body.appendChild(boardPane);
|
||||
|
||||
// Library
|
||||
// Library, with a name search
|
||||
const libPane = el('div', 'bl-pane bl-lib-pane');
|
||||
const search = el('input', 'bl-lib-search');
|
||||
search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
|
||||
libPane.appendChild(search);
|
||||
const lib = el('div', 'bl-library');
|
||||
const list = state?.library || [];
|
||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||
for (const person of list) {
|
||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||
const item = el('div', 'bl-lib-item'); item.dataset.id = person.id;
|
||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||
@@ -219,19 +295,27 @@ export default function create({ root, moduleApi }) {
|
||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||
item.appendChild(del);
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
item.addEventListener('dragend', stopDragAutoScroll);
|
||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
makeDraggable(item, 'lib', person.id, body, person.name);
|
||||
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
lib.appendChild(item);
|
||||
}
|
||||
body.appendChild(lib);
|
||||
const applyFilter = () => {
|
||||
const q = libFilter.trim().toLowerCase();
|
||||
for (const item of lib.querySelectorAll('.bl-lib-item')) {
|
||||
const nm = item.querySelector('.bl-lib-name');
|
||||
item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
|
||||
}
|
||||
};
|
||||
search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
|
||||
applyFilter();
|
||||
libPane.appendChild(lib);
|
||||
body.appendChild(libPane);
|
||||
wrap.appendChild(body);
|
||||
|
||||
root.innerHTML = '';
|
||||
root.appendChild(wrap);
|
||||
boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
|
||||
if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
|
||||
}
|
||||
|
||||
function assign(slotIndex, personId) {
|
||||
@@ -260,7 +344,7 @@ export default function create({ root, moduleApi }) {
|
||||
title: 'Sync',
|
||||
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
||||
});
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
|
||||
// Columns −/+ (touch-friendly; replaces the gear number field).
|
||||
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
||||
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
@@ -293,6 +377,10 @@ export default function create({ root, moduleApi }) {
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
stopDragAutoScroll();
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
|
||||
+133
-11
@@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) {
|
||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||
*/
|
||||
const MIN_SCORE = 60;
|
||||
|
||||
/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */
|
||||
function mixNumbers(value) {
|
||||
const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m;
|
||||
while ((m = re.exec(String(value || '')))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a person's Planning Center note names a spot. The booth writes the mic/role into each
|
||||
* vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those
|
||||
* change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a
|
||||
* different mix than the spot is never a match; the role part ("VOX 5") is scored like a position.
|
||||
* 0 means the note says nothing usable about this spot ("NA", blank, or no overlap).
|
||||
*/
|
||||
function noteScore(slotLabel, note) {
|
||||
const text = String(note || '').trim();
|
||||
if (!text || /^n\/?a$/i.test(text)) return 0;
|
||||
const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text);
|
||||
let mixBonus = 0;
|
||||
if (noteMix.size && slotMix.size) {
|
||||
if (![...noteMix].some((n) => slotMix.has(n))) return 0;
|
||||
mixBonus = 100;
|
||||
}
|
||||
const role = text.replace(/\bmix\s*\d+\b/gi, ' ');
|
||||
const roleScore = scoreMatch(slotRoleText(slotLabel), role);
|
||||
if (!roleScore && !mixBonus) return 0;
|
||||
if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) {
|
||||
// The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither.
|
||||
const roleTokens = labelTokens(role);
|
||||
if (roleTokens.length) return 0;
|
||||
}
|
||||
return mixBonus + roleScore;
|
||||
}
|
||||
|
||||
function assignMembersToSlots(slots, members) {
|
||||
const pool = members.slice();
|
||||
return slots.map((slotLabel) => {
|
||||
const result = slots.map(() => null);
|
||||
const take = (chosen) => {
|
||||
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
|
||||
const key = chosen.personId || '';
|
||||
for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1);
|
||||
};
|
||||
// Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first.
|
||||
const pairs = [];
|
||||
slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); }));
|
||||
pairs.sort((a, b) => b.sc - a.sc);
|
||||
for (const { s, m } of pairs) {
|
||||
if (result[s] || !pool.includes(m)) continue;
|
||||
result[s] = m; take(m);
|
||||
}
|
||||
// Pass 2 — everyone else by Planning Center position, into the spots still open.
|
||||
return result.map((placed, s) => {
|
||||
if (placed) return placed;
|
||||
const slotLabel = slots[s];
|
||||
const role = slotRoleText(slotLabel);
|
||||
let bestIndex = -1;
|
||||
let bestScore = MIN_SCORE - 1;
|
||||
@@ -128,18 +180,66 @@ function assignMembersToSlots(slots, members) {
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
return pool.splice(bestIndex, 1)[0];
|
||||
const chosen = pool[bestIndex];
|
||||
take(chosen);
|
||||
return chosen;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── photos by instrument ──────────────────────────────────────────────
|
||||
* A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg",
|
||||
* "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo
|
||||
* whose instrument matches the spot the person is in that week. Instruments group into families so
|
||||
* the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass,
|
||||
* Guitar/Electric/Acoustic/Rhythm, Leader. */
|
||||
const ROLE_FAMILY = {
|
||||
vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys',
|
||||
drums: 'drums', percussion: 'drums', bass: 'bass',
|
||||
guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader',
|
||||
};
|
||||
/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */
|
||||
function roleFamilies(text) {
|
||||
const out = new Set();
|
||||
for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); }
|
||||
return out;
|
||||
}
|
||||
/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */
|
||||
function spotFamilies(slotLabel, positionLabel) {
|
||||
const fromSlot = roleFamilies(slotRoleText(slotLabel));
|
||||
return fromSlot.size ? fromSlot : roleFamilies(positionLabel || '');
|
||||
}
|
||||
/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}.
|
||||
* The first word is always the person; instrument words after it are read off, other words
|
||||
* ("action", "2") are just part of the photo's description. */
|
||||
function photoParts(libName) {
|
||||
const words = normalizeLabel(libName).split(' ').filter(Boolean);
|
||||
const roles = new Set(); const person = [];
|
||||
words.forEach((w, i) => {
|
||||
const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null;
|
||||
if (f) roles.add(f); else if (!roles.size) person.push(w);
|
||||
});
|
||||
return { person: person.join(' '), roles };
|
||||
}
|
||||
/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick
|
||||
* never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under
|
||||
* its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */
|
||||
const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar'];
|
||||
function photoKey(personId, families) {
|
||||
const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || '';
|
||||
return f ? `${personId}|${f}` : String(personId || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||||
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||||
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
|
||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||||
* `entries` is [{ ...whatever, name }].
|
||||
* preferring the most specific. Returns the entry, or null when no library photo is a confident
|
||||
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
|
||||
* not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
|
||||
* `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the
|
||||
* person is on this week: a photo for that instrument beats their everyday photo, which beats a
|
||||
* photo of them on something else — but any photo of theirs still beats PCO's headshot.
|
||||
*/
|
||||
function bestLibraryMatch(pcoName, entries) {
|
||||
function bestLibraryMatch(pcoName, entries, want) {
|
||||
const target = normalizeLabel(pcoName);
|
||||
if (!target) return null;
|
||||
const T = target.split(' ').filter(Boolean);
|
||||
@@ -171,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
|
||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||
return 0;
|
||||
};
|
||||
const wanted = want && want.size ? want : null;
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
let tie = false;
|
||||
for (const e of entries) {
|
||||
const s = scoreOf(e.name);
|
||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||||
else if (s === bestScore && s > 0) tie = true;
|
||||
const parts = photoParts(e.name);
|
||||
let s = scoreOf(parts.roles.size ? parts.person : e.name);
|
||||
if (s < 60) continue; // not them
|
||||
if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20;
|
||||
if (s > bestScore) { bestScore = s; best = e; }
|
||||
}
|
||||
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
|
||||
if (!best) return null; // no confident match → PCO photo
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could this library photo be of this Planning Center person? Used when someone drops a photo onto
|
||||
* a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
|
||||
* A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
|
||||
* first three letters (a nickname: "Rose" → "Rosaliz").
|
||||
*/
|
||||
function samePerson(pcoName, libName) {
|
||||
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
|
||||
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || '';
|
||||
if (a && a === b) return true;
|
||||
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
samePerson,
|
||||
roleFamilies,
|
||||
spotFamilies,
|
||||
photoParts,
|
||||
photoKey,
|
||||
normalizeTeamName,
|
||||
isAllowedTeamName,
|
||||
bestLibraryMatch,
|
||||
@@ -194,5 +314,7 @@ module.exports = {
|
||||
numbersIn,
|
||||
scoreMatch,
|
||||
assignMembersToSlots,
|
||||
noteScore,
|
||||
mixNumbers,
|
||||
MIN_SCORE,
|
||||
};
|
||||
|
||||
@@ -61,7 +61,83 @@ console.log('assignment:', JSON.stringify(out));
|
||||
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||
// exact full name beats a first-name photo
|
||||
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
||||
// tie → no match (two identical-scoring photos)
|
||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
|
||||
assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
|
||||
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
|
||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
|
||||
{
|
||||
const P = (label, name, personId) => ({ label, name, personId });
|
||||
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
|
||||
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
||||
console.log('one spot per person: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
|
||||
{
|
||||
const { noteScore } = require('./matching');
|
||||
const NA_SLOTS = [
|
||||
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
|
||||
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
|
||||
];
|
||||
const P = (label, name, personId, note) => ({ label, name, personId, note });
|
||||
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
|
||||
const oct25 = [
|
||||
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
|
||||
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
|
||||
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
|
||||
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
|
||||
];
|
||||
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
|
||||
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
|
||||
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
|
||||
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
|
||||
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
|
||||
// A note never pulls someone onto a different mix.
|
||||
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
|
||||
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
|
||||
console.log('notes: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- samePerson: is a dropped photo of the person already in the spot? ---
|
||||
{
|
||||
const { samePerson } = require('./matching');
|
||||
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
|
||||
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
|
||||
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
|
||||
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
|
||||
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
|
||||
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
|
||||
console.log('samePerson: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
|
||||
{
|
||||
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
|
||||
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
|
||||
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
|
||||
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
|
||||
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
|
||||
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
|
||||
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
|
||||
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
|
||||
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
|
||||
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
|
||||
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
|
||||
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
|
||||
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
|
||||
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
|
||||
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
|
||||
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
|
||||
assert.strictEqual(photoKey('dj', new Set()), 'dj');
|
||||
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
|
||||
console.log('photos by instrument: all assertions passed ✓');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "band-lineup",
|
||||
"name": "Band Lineup",
|
||||
"version": "1.11.0",
|
||||
"version": "1.13.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||
"client": "client.js",
|
||||
|
||||
@@ -32,7 +32,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
@@ -219,6 +219,27 @@ module.exports = {
|
||||
const settings = readJsonFile(settingsPath, {}) || {};
|
||||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
||||
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||||
// The photo the booth picked for a Planning Center person on an instrument ("<PCO person id>|drums"
|
||||
// → library id; a bare person id is their everyday pick), learned when a library photo is dropped
|
||||
// onto that person. Sync uses it before any name matching.
|
||||
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
|
||||
function rememberPhoto(key, libId) {
|
||||
if (!key || !libId || settings.photoFor[key] === libId) return;
|
||||
settings.photoFor[key] = libId; saveSettings();
|
||||
}
|
||||
// Library photo for a scheduled person in a spot whose instruments are `fams`:
|
||||
// 1. the photo picked for them on this instrument;
|
||||
// 2. a photo named for this instrument ("DJ - Drums");
|
||||
// 3. their everyday pick, else their best-named photo (an other-instrument photo of them
|
||||
// still beats PCO's headshot).
|
||||
function libraryPhotoFor(personId, name, fams) {
|
||||
const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null);
|
||||
const forRole = live(settings.photoFor[photoKey(personId, fams)]);
|
||||
if (forRole) return forRole;
|
||||
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams);
|
||||
if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m;
|
||||
return live(settings.photoFor[personId]) || (m && m.file ? m : null);
|
||||
}
|
||||
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||
|
||||
const CHOIR_LABEL = 'CHOIR';
|
||||
@@ -247,7 +268,7 @@ module.exports = {
|
||||
return {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||
plan: null,
|
||||
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
|
||||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||
library: [], choir: choirOn,
|
||||
};
|
||||
@@ -313,7 +334,7 @@ module.exports = {
|
||||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
||||
*/
|
||||
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums"
|
||||
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||
function scanFolder() {
|
||||
if (!photoFolder) return false;
|
||||
@@ -449,11 +470,13 @@ module.exports = {
|
||||
if (!e) return null;
|
||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||
if (e.pco) {
|
||||
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
|
||||
// A synced person. Their library photo (if any) is read live, so replacing that photo
|
||||
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
|
||||
manual = false; personId = e.pco.personId || '';
|
||||
name = String(e.pco.name || '').trim();
|
||||
file = e.pco.file || '';
|
||||
birthday = e.pco.birthday || '';
|
||||
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
||||
file = (lib && lib.file) || e.pco.file || '';
|
||||
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
||||
} else if (e.personId) {
|
||||
const lib = library[e.personId];
|
||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||
@@ -493,7 +516,8 @@ module.exports = {
|
||||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||
if (pco.configured && serviceTypeId) {
|
||||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
|
||||
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
|
||||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||
else {
|
||||
|
||||
@@ -511,6 +535,7 @@ module.exports = {
|
||||
personId,
|
||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||
label: String(m.attributes?.team_position_name || '').trim(),
|
||||
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||
});
|
||||
if (personId) seen.add(personId);
|
||||
@@ -519,15 +544,29 @@ module.exports = {
|
||||
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||||
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||||
|
||||
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
||||
// A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
|
||||
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
|
||||
// Planning Center name, kept in that spot.
|
||||
for (const [i, e] of Object.entries(board)) {
|
||||
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
|
||||
const libName = library[e.personId].name;
|
||||
const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
|
||||
if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
|
||||
const m = who[0];
|
||||
rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId);
|
||||
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan };
|
||||
}
|
||||
|
||||
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
|
||||
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
|
||||
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
|
||||
const eff = effectiveSlots();
|
||||
const planId = String(plan.id);
|
||||
const openIdx = []; const manualNames = new Set();
|
||||
for (let i = 0; i < eff.length; i += 1) {
|
||||
const e = board[String(i)];
|
||||
if (e && (e.personId || e.name)) {
|
||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||||
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (nm) manualNames.add(normName(nm));
|
||||
} else openIdx.push(i);
|
||||
}
|
||||
@@ -537,11 +576,12 @@ module.exports = {
|
||||
for (let k = 0; k < openIdx.length; k += 1) {
|
||||
const i = openIdx[k]; const m = assigned[k];
|
||||
if (m) {
|
||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||
} else if (board[String(i)] && board[String(i)].pco) {
|
||||
delete board[String(i)]; // was synced, no longer on the plan
|
||||
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
|
||||
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
|
||||
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
|
||||
} else if (board[String(i)]) {
|
||||
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||||
}
|
||||
}
|
||||
saveBoard();
|
||||
@@ -580,6 +620,9 @@ module.exports = {
|
||||
current = {
|
||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||
planId: () => (latest.plan && latest.plan.id) || '',
|
||||
rememberPhoto,
|
||||
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
|
||||
photoUrlFor, libraryList,
|
||||
setChoir, choirOn: () => choirOn,
|
||||
};
|
||||
@@ -670,6 +713,10 @@ module.exports = {
|
||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||
const a = current.board[from]; const b = current.board[to];
|
||||
// Moving a spot is a hand placement for this plan: Sync must not move it back.
|
||||
const plan = current.planId();
|
||||
if (a) a.plan = plan;
|
||||
if (b) b.plan = plan;
|
||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
@@ -684,7 +731,14 @@ module.exports = {
|
||||
const personId = String(q.get('personId') || '').trim();
|
||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||
if (personId) current.board[slot] = { personId, name: '' };
|
||||
const here = current.board[slot];
|
||||
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
|
||||
// A photo of the person already in this spot: it's their photo now — this week and every
|
||||
// Sync after. They keep their Planning Center name and their spot.
|
||||
// Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone.
|
||||
current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId);
|
||||
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
|
||||
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
|
||||
else delete current.board[slot];
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
@@ -702,6 +756,7 @@ module.exports = {
|
||||
const name = String(q.get('name') || '').trim();
|
||||
const existing = current.board[index] || { personId: '', name: '' };
|
||||
existing.name = name;
|
||||
existing.plan = current.planId();
|
||||
if (!existing.personId && !name) delete current.board[index];
|
||||
else current.board[index] = existing;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
|
||||
@@ -230,3 +230,34 @@
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
/* Drag: the floating photo that follows the pointer (appended to <body>, so it scopes itself). */
|
||||
[data-module="band-lineup"].bl-drag-ghost {
|
||||
position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none;
|
||||
width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden;
|
||||
border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92;
|
||||
}
|
||||
[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||
[data-module="band-lineup"] .bl-lib-item,
|
||||
[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
[data-module="band-lineup"] .bl-edit-slot img,
|
||||
[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; }
|
||||
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
|
||||
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
|
||||
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
|
||||
|
||||
/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
|
||||
[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
|
||||
[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
|
||||
[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
|
||||
[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
|
||||
[data-module="band-lineup"] .bl-lib-search {
|
||||
display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
|
||||
[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
|
||||
[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
|
||||
[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
|
||||
el.sources.hidden = !on('showSources') || !s.sources.length;
|
||||
if (!el.sources.hidden) {
|
||||
el.sources.innerHTML = s.sources.map((x) => `
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
|
||||
<span class="vc-dot"></span>${esc(x.label)}
|
||||
</span>`).join('');
|
||||
}
|
||||
@@ -186,6 +186,8 @@ export default function create({ root, moduleApi }) {
|
||||
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
||||
const connected = s.sources.filter((x) => x.connected);
|
||||
const waiting = s.sources.filter((x) => !x.connected);
|
||||
const off = s.sources.filter((x) => x.offNetwork);
|
||||
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
|
||||
if (!connected.length) {
|
||||
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
||||
return moduleApi.setStatus('error', why);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "vista-cues",
|
||||
"name": "Vista Cues",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
||||
"permissions": [
|
||||
@@ -131,6 +131,13 @@
|
||||
"default": true,
|
||||
"group": "This machine",
|
||||
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
|
||||
},
|
||||
"wiredNetwork": {
|
||||
"type": "string",
|
||||
"label": "Wired network",
|
||||
"default": "",
|
||||
"group": "This machine",
|
||||
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
|
||||
}
|
||||
},
|
||||
"configGroups": {
|
||||
|
||||
@@ -49,6 +49,44 @@ function emptyState(extra = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
|
||||
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
|
||||
* session once per network, so a sender typed or picked from the wrong copy
|
||||
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
|
||||
* throws on a typo so the admin page says so at once.
|
||||
*/
|
||||
function parseNets(text) {
|
||||
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
|
||||
const parts = m ? m.slice(1, 5).map(Number) : [];
|
||||
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
|
||||
if (!m || parts.some((x) => x > 255) || bits > 32) {
|
||||
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
|
||||
}
|
||||
return { base: ipNumber(parts.join('.')), bits, text: n };
|
||||
});
|
||||
}
|
||||
|
||||
function ipNumber(a) {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
|
||||
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an address is not on the wired network: '' when it is (or there is no
|
||||
* rule), 'by name' for a host name (it resolves to whichever network answers
|
||||
* first), 'not wired' for an address outside every wired network.
|
||||
*/
|
||||
function offNetwork(nets, address) {
|
||||
if (!nets.length || !address) return '';
|
||||
const v = ipNumber(address);
|
||||
if (v === null) return 'by name';
|
||||
if (address.startsWith('127.')) return '';
|
||||
const span = (bits) => 2 ** (32 - bits);
|
||||
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
|
||||
}
|
||||
|
||||
function endpointOf(v) {
|
||||
const ep = v && typeof v === 'object' ? v : {};
|
||||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||||
@@ -62,6 +100,8 @@ module.exports = {
|
||||
const exclusive = config.exclusive === true;
|
||||
const headTakesOver = config.headTakesOver !== false;
|
||||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||||
const nets = parseNets(config.wiredNetwork);
|
||||
const netText = nets.map((n) => n.text).join(', ');
|
||||
if (mappingErrors.length) {
|
||||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||||
}
|
||||
@@ -187,6 +227,9 @@ module.exports = {
|
||||
label: s.label,
|
||||
name: p ? p.name : '',
|
||||
address: inv?.address || s.host,
|
||||
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
|
||||
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
|
||||
host: s.host,
|
||||
configured: true,
|
||||
connected: Boolean(p),
|
||||
error: p ? '' : (inv?.error || ''),
|
||||
@@ -195,7 +238,7 @@ module.exports = {
|
||||
}
|
||||
for (const p of session.participants.values()) {
|
||||
if (seen.has(p.key) || !p.connected) continue;
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -219,6 +262,7 @@ module.exports = {
|
||||
sessionName,
|
||||
port,
|
||||
advertised,
|
||||
wiredNetwork: netText,
|
||||
sources: sources(),
|
||||
current: cur || null,
|
||||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||||
@@ -304,6 +348,16 @@ module.exports = {
|
||||
health() {
|
||||
if (session.error) return { status: 'error', message: session.error };
|
||||
const src = sources();
|
||||
// Off the wired network: still joined (the cues keep working), but said first and in red.
|
||||
const off = src.filter((s) => s.offNetwork);
|
||||
if (off.length) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${off.map((s) => (s.offNetwork === 'by name'
|
||||
? `${s.label} is set by name (${s.host})`
|
||||
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
|
||||
};
|
||||
}
|
||||
const on = src.filter((s) => s.connected);
|
||||
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||||
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||||
@@ -323,6 +377,9 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
|
||||
// For the tests.
|
||||
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
|
||||
|
||||
/**
|
||||
* One picker entry, so the + menu gives Vista Cues a heading of its own —
|
||||
* a module with no list sits headless under whichever module precedes it
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
}
|
||||
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
|
||||
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
|
||||
@container (max-height: 140px) {
|
||||
[data-module="vista-cues"] .vc-sources { display: none; }
|
||||
}
|
||||
|
||||
@@ -363,6 +363,53 @@ async function freePair() {
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('wired network: addresses outside it, and names, are flagged', () => {
|
||||
const mod = require('./server');
|
||||
const off = mod._offNetwork;
|
||||
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
|
||||
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
|
||||
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
|
||||
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
|
||||
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
|
||||
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
|
||||
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
|
||||
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
|
||||
});
|
||||
|
||||
await test('server entry: a sender off the wired network still joins, and says so', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake Tracks');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
|
||||
const handle = mod.init({
|
||||
config: {
|
||||
sessionPort: port,
|
||||
advertise: false,
|
||||
wiredNetwork: '10.1.0.0/16',
|
||||
source2Name: 'Tracks',
|
||||
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
|
||||
cueLists: '60 = Walk-in',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
await until(() => sender.peers.length > 0); // joined all the same
|
||||
const h = handle.health();
|
||||
assert.strictEqual(h.status, 'error');
|
||||
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
|
||||
const routes = mod.routes({});
|
||||
let body = '';
|
||||
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
|
||||
const st = JSON.parse(body).state;
|
||||
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
|
||||
assert.strictEqual(st.sources[0].offNetwork, 'by name');
|
||||
handle.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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.
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,78 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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)")
|
||||
@@ -0,0 +1,110 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 : [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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()
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/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