Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fffefc6c0 | ||
|
|
227e3bb846 | ||
|
|
459d8b9c38 | ||
|
|
6b51044be6 |
@@ -13,6 +13,3 @@ launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
launcher/windows/build/
|
||||
launcher/windows/.cache/
|
||||
|
||||
# the Viewer's build output
|
||||
viewer/macos/build/
|
||||
|
||||
@@ -4,13 +4,10 @@ Modular production dashboard web app for Waters Church, developed in this repo.
|
||||
The app lives at the repo root and is built: zero-dependency Node server
|
||||
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
||||
none and installs them from the admin page's catalog), a native macOS menu-bar launcher in
|
||||
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
|
||||
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||
the server and its own Node runtime, supervises them, and owns the macOS
|
||||
permissions modules declare; see its README), and **ProdDash Viewer** in
|
||||
`viewer/macos/` (Swift, no dependencies — a separate Apple-silicon app that
|
||||
only displays one or more ProdDash installations in WebKit, with an
|
||||
auto-hiding tab bar; it runs no server; see its README). Runtime state (module
|
||||
permissions modules declare; see its README). Runtime state (module
|
||||
config, named layouts) is written to a per-machine data directory outside the
|
||||
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
||||
kept") — never add code that writes runtime state into the repo folder.
|
||||
@@ -56,6 +53,7 @@ title-bar controls). The rules that matter most:
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`).
|
||||
`node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=<email>:<password>`).
|
||||
|
||||
## Reference code (read-only forks — don't develop these here)
|
||||
|
||||
|
||||
@@ -515,6 +515,29 @@ below. Nothing is installed without someone clicking.
|
||||
read from the account), and a folded **Timing** group for the poll
|
||||
intervals and a position trim.
|
||||
Tile settings: cue name, previous cue, playhead, cue list.
|
||||
- **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper
|
||||
app does, inside ProdDash, speaking the same Dashboard API it does: the
|
||||
live slide's notes are read from ProPresenter (the presentation layer and
|
||||
the announcement layer), and when they carry one of the keywords mapped in
|
||||
the Overflow Dashboard, Overflow is told — and points that keyword's Tap
|
||||
groups at its destination, so a tap on a disc follows what is on screen.
|
||||
A slide with no keyword changes nothing. Keywords match as the helper
|
||||
matches them: as a whole word of the notes, any case, punctuation around
|
||||
it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is
|
||||
one keyword, big: what Tap is on, its destination and the groups moved, a
|
||||
chip saying whether the slide on screen carries a keyword, and the last
|
||||
four switches with their times. How a switch went is the corner dot's
|
||||
job: a switch Overflow refuses is retried three times with the dot left
|
||||
alone, and only when those are spent does the dot go red (hover it for
|
||||
the reason) and, if switched on in admin, an alert channel flash. Admin
|
||||
config: the ProPresenter endpoint and an
|
||||
**Overflow Dashboard login** (email and password; Google sign-in has no
|
||||
password, so set one with "Forgot Your Password?"). The keywords, their
|
||||
destinations and their Tap groups stay where Overflow keeps them —
|
||||
Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
|
||||
read from there on start, every few minutes, and on the tile's refresh
|
||||
button. Tile settings are four switches: destination, groups, slide
|
||||
match, history.
|
||||
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
|
||||
slot with photo, name and mic/pack label, on one shared board that every
|
||||
screen sees the same way — whoever edits it changes it everywhere, over SSE.
|
||||
@@ -615,21 +638,15 @@ board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI session playing a cue
|
||||
sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
||||
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
|
||||
```
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints) and
|
||||
`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
|
||||
keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
|
||||
Dashboard API on `127.0.0.1:24720` — run ProdDash with
|
||||
`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
|
||||
any email and password; `--fail-switch` refuses every switch,
|
||||
`--expire=20` makes sessions expire, `--login=<email>:<password>` accepts
|
||||
one login only). See the testing section of the
|
||||
[module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
@@ -661,9 +678,6 @@ ProdDash/
|
||||
├── package.json name + version (what the update check compares)
|
||||
├── config/ proddash.json defaults (runtime state lives in the
|
||||
│ per-machine data directory — see "Configure")
|
||||
├── launcher/macos/ the macOS app: menu-bar launcher carrying the server
|
||||
│ and its own Node (launcher/windows: the Windows bundle)
|
||||
├── viewer/macos/ ProdDash Viewer: a Mac app that only shows dashboards
|
||||
├── docs/MODULE-GUIDE.md how to build and install a module
|
||||
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
|
||||
├── tools/prodcom-mock.js mock ProdCom API for development
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/* Overflow Tap — the tile.
|
||||
*
|
||||
* One fact matters across a booth: which keyword Tap is on right now. That
|
||||
* is the big line. Under it, the destination it maps to and the groups it
|
||||
* moved; a small line says whether the slide on screen carries a keyword at
|
||||
* all; and the last four switches sit at the bottom with their times, so a
|
||||
* tech can see the service's trail. Whether the last switch landed is the
|
||||
* corner dot's to say — red when Overflow refused it and the module's own
|
||||
* retries did not fix that — never a line of text in the tile.
|
||||
*
|
||||
* Everything comes from the server's `state` stream. The tile never talks to
|
||||
* ProPresenter or Overflow itself.
|
||||
*/
|
||||
|
||||
const HISTORY_SHOWN = 4;
|
||||
|
||||
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5v3h-3"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let stream = null;
|
||||
let refreshBtn = null;
|
||||
let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state
|
||||
|
||||
const settings = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => settings()[key] !== false;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function clock(ms) {
|
||||
if (!ms) return '';
|
||||
try {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-line reason the tile cannot do its job, or '' when it can. */
|
||||
function blocker() {
|
||||
if (!state) return 'Waiting for ProdDash';
|
||||
const pp = state.propresenter || {};
|
||||
const of = state.overflow || {};
|
||||
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
|
||||
if (!of.configured) return 'No Overflow login — add it in /admin';
|
||||
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
|
||||
return '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
root.textContent = '';
|
||||
const box = el('div', 'ot-root');
|
||||
root.append(box);
|
||||
|
||||
const stop = blocker();
|
||||
if (stop) {
|
||||
box.append(el('div', 'ot-empty', stop));
|
||||
return;
|
||||
}
|
||||
|
||||
const pp = state.propresenter || {};
|
||||
const of = state.overflow || {};
|
||||
const cur = state.current;
|
||||
const offline = streamDown || !pp.reachable || !of.reachable;
|
||||
|
||||
if (offline) {
|
||||
box.classList.add('is-offline');
|
||||
const why = streamDown ? 'ProdDash connection lost'
|
||||
: !of.reachable ? (of.error || 'Overflow unreachable')
|
||||
: (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable');
|
||||
box.append(el('div', 'ot-offline', why));
|
||||
}
|
||||
|
||||
// The keyword Tap is on, its destination and the groups moved. No status
|
||||
// words here: how the switch went is the corner dot's job (its hover
|
||||
// carries the reason), and the trail's ✓ / ✕ keeps the record. The
|
||||
// keyword's colour follows along — dimmed while a switch is in flight,
|
||||
// red once Overflow has refused it for good.
|
||||
const main = el('div', 'ot-main');
|
||||
if (cur) {
|
||||
main.dataset.tone = cur.pending ? 'muted' : (cur.ok ? 'ok' : 'alert');
|
||||
main.append(el('div', 'ot-keyword', cur.keyword));
|
||||
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
|
||||
if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
|
||||
} else {
|
||||
main.dataset.tone = 'muted';
|
||||
main.append(el('div', 'ot-keyword is-none', '—'));
|
||||
main.append(el('div', 'ot-dest', 'No keyword sent yet'));
|
||||
}
|
||||
box.append(main);
|
||||
|
||||
if (on('showSlide')) {
|
||||
const slide = state.slide || {};
|
||||
const ann = state.announcement || {};
|
||||
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
|
||||
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
|
||||
let text;
|
||||
let cls = 'ot-slide';
|
||||
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
|
||||
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
|
||||
else if (slideKeys.length || annKeys.length) {
|
||||
const parts = [];
|
||||
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
|
||||
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
|
||||
text = parts.join(' · ');
|
||||
cls += ' is-hit';
|
||||
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
|
||||
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
|
||||
box.append(el('div', cls, text));
|
||||
}
|
||||
|
||||
if (on('showHistory')) {
|
||||
const rows = (state.history || []).slice(0, HISTORY_SHOWN);
|
||||
const list = el('ol', 'ot-history');
|
||||
if (!rows.length) {
|
||||
list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet'));
|
||||
}
|
||||
for (const h of rows) {
|
||||
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
|
||||
row.title = h.ok
|
||||
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
|
||||
: `${h.keyword}: ${h.error || 'failed'}`;
|
||||
row.append(el('span', 'ot-h-time', clock(h.at)));
|
||||
row.append(el('span', 'ot-h-kw', h.keyword));
|
||||
row.append(el('span', 'ot-h-dest', h.destination || ''));
|
||||
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
|
||||
list.append(row);
|
||||
}
|
||||
box.append(list);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash');
|
||||
const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting';
|
||||
moduleApi.setStatus(s, state.message || '');
|
||||
}
|
||||
|
||||
function handleState(e) {
|
||||
try {
|
||||
state = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
streamDown = false;
|
||||
applyStatus();
|
||||
render();
|
||||
}
|
||||
|
||||
async function backfill() {
|
||||
try {
|
||||
const res = await moduleApi.fetch('/state');
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
if (!body || !body.state || !stream) return;
|
||||
state = body.state;
|
||||
streamDown = false;
|
||||
applyStatus();
|
||||
render();
|
||||
} catch {
|
||||
/* the stream will bring the next state */
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!refreshBtn) return;
|
||||
refreshBtn.classList.add('active');
|
||||
try {
|
||||
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
let why = `Keyword refresh failed (${res.status})`;
|
||||
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
|
||||
moduleApi.setStatus('error', why);
|
||||
}
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
|
||||
} finally {
|
||||
refreshBtn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting');
|
||||
refreshBtn = moduleApi.header.addButton({
|
||||
icon: ICON_REFRESH,
|
||||
title: 'Refresh keywords',
|
||||
onClick: refresh,
|
||||
});
|
||||
stream = moduleApi.sse('/stream', {
|
||||
// A fresh connection replays the state; a tile joining a stream the
|
||||
// page already carries gets only `open`, and the server sends state
|
||||
// on change alone — so backfill, or a second tile waits for the next
|
||||
// switch to draw anything.
|
||||
open() { backfill(); },
|
||||
error() {
|
||||
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
|
||||
streamDown = true;
|
||||
render();
|
||||
},
|
||||
events: { state: handleState },
|
||||
});
|
||||
render();
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (stream) stream.close();
|
||||
stream = null;
|
||||
refreshBtn = null;
|
||||
root.textContent = '';
|
||||
},
|
||||
|
||||
onConfigChange() {
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Keyword matching — which of Overflow's keywords a slide's notes carry.
|
||||
*
|
||||
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
|
||||
*
|
||||
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
|
||||
* keywordMatches(notes, keyword) → boolean
|
||||
* matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
|
||||
* sameSet(a, b) → the two keyword lists hold the same keywords
|
||||
*
|
||||
* The rule is Overflow's own helper's: the notes are split into whitespace-
|
||||
* separated words, each stripped of surrounding punctuation and lower-cased,
|
||||
* and a keyword fires when it is one of those words. So `give` fires on
|
||||
* "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
|
||||
* or "give_now". Phrases work too, bounded the same way on both ends —
|
||||
* something the helper cannot do, but nothing it would have matched is lost.
|
||||
*/
|
||||
|
||||
const ZERO_WIDTH = /[-]/g;
|
||||
|
||||
function cleanNotes(raw) {
|
||||
return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
function norm(s) {
|
||||
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function keywordMatches(notes, keyword) {
|
||||
const n = cleanNotes(notes);
|
||||
const k = norm(keyword);
|
||||
if (!k || !n.trim()) return false;
|
||||
const flat = n.toLowerCase().replace(/\s+/g, ' ');
|
||||
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
|
||||
return re.test(flat);
|
||||
}
|
||||
|
||||
/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
|
||||
function matchKeywords(notes, keywords) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const raw of keywords || []) {
|
||||
const k = typeof raw === 'string' ? raw : raw && raw.keyword;
|
||||
const key = norm(k);
|
||||
if (!key || seen.has(key)) continue;
|
||||
if (keywordMatches(notes, k)) {
|
||||
seen.add(key);
|
||||
out.push(String(k).trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sameSet(a, b) {
|
||||
const x = [...(a || [])].map(norm).sort();
|
||||
const y = [...(b || [])].map(norm).sort();
|
||||
return x.length === y.length && x.every((v, i) => v === y[i]);
|
||||
}
|
||||
|
||||
module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet };
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-test for keywords.js — the matching rules, pinned.
|
||||
*
|
||||
* node modules/overflow-tap/keywords.selftest.js
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
|
||||
|
||||
let n = 0;
|
||||
function test(name, fn) {
|
||||
n += 1;
|
||||
try {
|
||||
fn();
|
||||
console.log(`ok ${name}`);
|
||||
} catch (err) {
|
||||
console.log(`FAIL ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
test('a keyword fires as a word of the notes, whatever the case', () => {
|
||||
assert.equal(keywordMatches('give', 'give'), true);
|
||||
assert.equal(keywordMatches('Give', 'give'), true);
|
||||
assert.equal(keywordMatches('please GIVE today', 'give'), true);
|
||||
});
|
||||
|
||||
test('surrounding punctuation is ignored, as the helper strips it', () => {
|
||||
assert.equal(keywordMatches('give!', 'give'), true);
|
||||
assert.equal(keywordMatches('#give now', 'give'), true);
|
||||
assert.equal(keywordMatches('go to (give) now', 'give'), true);
|
||||
assert.equal(keywordMatches('"give"', 'give'), true);
|
||||
assert.equal(keywordMatches('give.', 'give'), true);
|
||||
});
|
||||
|
||||
test('a keyword inside another word does not fire', () => {
|
||||
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
|
||||
assert.equal(keywordMatches('giveaway', 'give'), false);
|
||||
assert.equal(keywordMatches('give_now', 'give'), false);
|
||||
});
|
||||
|
||||
test('a phrase keyword is bounded the same way', () => {
|
||||
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
|
||||
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
|
||||
});
|
||||
|
||||
test('regex characters in a keyword are literal', () => {
|
||||
assert.equal(keywordMatches('give.now', 'give.now'), true);
|
||||
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
|
||||
});
|
||||
|
||||
test('empty notes never match; zero-width padding is ignored', () => {
|
||||
assert.equal(keywordMatches('', 'give'), false);
|
||||
assert.equal(keywordMatches('', 'give'), false);
|
||||
assert.equal(cleanNotes('give'), 'give');
|
||||
assert.equal(keywordMatches('give', 'give'), true);
|
||||
});
|
||||
|
||||
test('matchKeywords returns every hit, in list order, once each', () => {
|
||||
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
|
||||
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
|
||||
assert.deepEqual(matchKeywords('just worship', list), []);
|
||||
assert.deepEqual(matchKeywords('give', []), []);
|
||||
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
|
||||
});
|
||||
|
||||
test('sameSet compares keyword lists regardless of order and case', () => {
|
||||
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
|
||||
assert.equal(sameSet(['give'], ['give', 'connect']), false);
|
||||
assert.equal(sameSet([], []), true);
|
||||
assert.equal(sameSet(['give'], []), false);
|
||||
});
|
||||
|
||||
console.log(`${n} tests, exit ${process.exitCode || 0}`);
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"id": "overflow-tap",
|
||||
"name": "Overflow Tap",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.11.0",
|
||||
"description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching ProPresenter on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 3
|
||||
},
|
||||
"configGroups": {
|
||||
"ProPresenter": {
|
||||
"help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes (and the announcement layer's) are read — nothing on the stage screens changes."
|
||||
},
|
||||
"Overflow": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes."
|
||||
},
|
||||
"Alerts": {
|
||||
"toggle": "alertEnabled",
|
||||
"collapsed": true,
|
||||
"help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"group": "ProPresenter",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "API password",
|
||||
"group": "ProPresenter",
|
||||
"default": "",
|
||||
"help": "Only if a password is set in ProPresenter's network preferences."
|
||||
},
|
||||
"overflowEmail": {
|
||||
"type": "string",
|
||||
"label": "Email",
|
||||
"group": "Overflow",
|
||||
"default": ""
|
||||
},
|
||||
"overflowPassword": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"group": "Overflow",
|
||||
"default": ""
|
||||
},
|
||||
"nonprofitId": {
|
||||
"type": "string",
|
||||
"label": "Nonprofit ID",
|
||||
"group": "Overflow",
|
||||
"default": "",
|
||||
"help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
|
||||
},
|
||||
"alertEnabled": {
|
||||
"type": "switch",
|
||||
"label": "Alert when a switch fails",
|
||||
"group": "Alerts",
|
||||
"default": false
|
||||
},
|
||||
"alertChannel": {
|
||||
"type": "number",
|
||||
"label": "Channel",
|
||||
"group": "Alerts",
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 6,
|
||||
"help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showDestination": {
|
||||
"type": "switch",
|
||||
"label": "Destination",
|
||||
"default": true
|
||||
},
|
||||
"showGroups": {
|
||||
"type": "switch",
|
||||
"label": "Groups",
|
||||
"default": true
|
||||
},
|
||||
"showSlide": {
|
||||
"type": "switch",
|
||||
"label": "Slide match",
|
||||
"default": true
|
||||
},
|
||||
"showHistory": {
|
||||
"type": "switch",
|
||||
"label": "History",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration"
|
||||
* helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is
|
||||
* in Overflow's public API reference (which documents only reads for Tap): it
|
||||
* is the Dashboard's own backend, used with a Dashboard login — the same
|
||||
* email and password the helper asks for. Google sign-in does not work here;
|
||||
* the account needs a password (Overflow's article says the same).
|
||||
*
|
||||
* POST /nonprofit-dashboard/authentication/login/jwt
|
||||
* { email, password } → { token } a JWT; its payload carries
|
||||
* email, nonprofitId, permissions [{ action, subject }] (subject TAP
|
||||
* or all is required), nonprofits [...], exp
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords
|
||||
* → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ]
|
||||
* the Dashboard's Integrations › ProPresenter › Keyword Mappings
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true
|
||||
* { keywords: ["give"] } → the same list, updated THE SWITCH:
|
||||
* Overflow points the keyword's groups at its destination
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit=
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select
|
||||
* only for an @overflow.co staff login, which has no nonprofit of its
|
||||
* own and must pick one first
|
||||
*
|
||||
* Bearer auth on everything after login. A 401 means the session is gone —
|
||||
* sign in again. The helper's own wording for a refused login is kept here
|
||||
* (401 invalid email or password, 429 too many attempts, 403 locked).
|
||||
*
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
|
||||
* tools/overflow-tap-mock.js.
|
||||
*/
|
||||
|
||||
const DEFAULT_API_BASE = 'https://server.overflow.co';
|
||||
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
const DASH = '/nonprofit-dashboard';
|
||||
const TIMEOUT_MS = 10000;
|
||||
const TRIGGER_TIMEOUT_MS = 15000;
|
||||
|
||||
class OverflowError extends Error {
|
||||
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
|
||||
super(message);
|
||||
this.name = 'OverflowError';
|
||||
this.status = status;
|
||||
this.retryable = retryable;
|
||||
this.auth = auth; // the session was refused: sign in again before retrying
|
||||
}
|
||||
}
|
||||
|
||||
/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
|
||||
function decodeJwt(token) {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the module needs to know about a session, from its token. */
|
||||
function describeSession(token) {
|
||||
const p = decodeJwt(token) || {};
|
||||
const email = String(p.email || '');
|
||||
const perms = Array.isArray(p.permissions) ? p.permissions : [];
|
||||
const nonprofits = Array.isArray(p.nonprofits)
|
||||
? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id)
|
||||
: [];
|
||||
return {
|
||||
email,
|
||||
staff: email.toLowerCase().endsWith('@overflow.co'),
|
||||
nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '',
|
||||
nonprofits,
|
||||
hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')),
|
||||
expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
|
||||
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
|
||||
const url = new URL(API_BASE + path);
|
||||
for (const [k, v] of Object.entries(query || {})) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeout);
|
||||
let res;
|
||||
try {
|
||||
res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
|
||||
} catch (err) {
|
||||
const aborted = err && err.name === 'AbortError';
|
||||
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
||||
throw new OverflowError(
|
||||
aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`,
|
||||
{ retryable: true },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch { data = null; }
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail = data && (data.message || data.error || data.detail);
|
||||
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : '');
|
||||
throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
|
||||
status: res.status,
|
||||
auth: res.status === 401,
|
||||
retryable: res.status === 429 || res.status >= 500,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
base: API_BASE,
|
||||
|
||||
/** Sign in with a Dashboard login; resolves to the JWT. */
|
||||
async login(email, password) {
|
||||
try {
|
||||
const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
|
||||
const token = data && data.token;
|
||||
if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token');
|
||||
return token;
|
||||
} catch (err) {
|
||||
if (!(err instanceof OverflowError) || !err.status) throw err;
|
||||
if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 });
|
||||
if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true });
|
||||
if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
|
||||
async listKeywords(token, nonprofitId) {
|
||||
const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token });
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
|
||||
/** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */
|
||||
async trigger(token, nonprofitId, keywords) {
|
||||
const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, {
|
||||
query: { syncNonprofit: 'true' },
|
||||
body: { keywords },
|
||||
token,
|
||||
timeout: TRIGGER_TIMEOUT_MS,
|
||||
});
|
||||
return Array.isArray(data) ? data : null;
|
||||
},
|
||||
|
||||
/** Staff logins only: the nonprofits this login may act for. */
|
||||
async searchNonprofits(token, search = '', limit = 10) {
|
||||
const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } });
|
||||
const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data;
|
||||
return Array.isArray(list)
|
||||
? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') }))
|
||||
: [];
|
||||
},
|
||||
|
||||
/** Staff logins only: bind the session to one nonprofit before reading its keywords. */
|
||||
selectNonprofit(token, nonprofitId) {
|
||||
return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
|
||||
@@ -0,0 +1,678 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Overflow Tap — server entry.
|
||||
*
|
||||
* Does what Overflow's "ProPresenter Integration" helper app does, inside
|
||||
* ProdDash: watches the live slide in ProPresenter (the presentation layer
|
||||
* and the announcement layer), and when its notes carry one of the keywords
|
||||
* mapped in the Overflow Dashboard, tells Overflow — which points that
|
||||
* keyword's Tap groups at its destination, so what people reach when they
|
||||
* tap a disc follows what is on screen. A slide with no keyword changes
|
||||
* nothing; the last destination stays until the next keyworded slide.
|
||||
*
|
||||
* A switch Overflow refuses is retried three times, ten seconds apart, with
|
||||
* the tile's dot left alone meanwhile; only when those are spent does the
|
||||
* dot go red (and the alert fire, if switched on) — the booth hears about a
|
||||
* problem the module could not fix itself, not about every hiccup.
|
||||
*
|
||||
* GET /state → the view-model every tile draws
|
||||
* GET /stream → SSE, `state` events on every change (heartbeated)
|
||||
* POST /refresh → re-read the keyword mappings from the Dashboard now
|
||||
* GET /keywords → the mappings as read, for a look while setting up
|
||||
*
|
||||
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
|
||||
* GET /v1/announcement/slide_index for announcements, once a second each —
|
||||
* small requests, nothing heavier) because its chunked update stream is the
|
||||
* thing that goes quiet without saying so. Overflow is the Dashboard's own
|
||||
* API, signed in with a Dashboard login — the calls, and where they come
|
||||
* from, are at the top of overflow.js. Keyword mappings are read on start,
|
||||
* every few minutes after that, on /refresh, and from every trigger's answer.
|
||||
*
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at
|
||||
* tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
|
||||
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
|
||||
|
||||
const POLL_MS = 1000; // ProPresenter, while it answers
|
||||
const RETRY_MS = 3000; // ProPresenter, while it does not
|
||||
const PP_TIMEOUT_MS = 4000;
|
||||
const HEARTBEAT_MS = 15000;
|
||||
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
|
||||
const OVERFLOW_RETRY_MS = 30 * 1000;
|
||||
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
|
||||
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
|
||||
const SWITCH_RETRY_MS = 10 * 1000;
|
||||
const SWITCH_RETRIES = 3;
|
||||
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
|
||||
const ALERT_SECONDS = 30;
|
||||
|
||||
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
|
||||
let current = null;
|
||||
|
||||
/**
|
||||
* The signed-in session, kept across re-inits for the same login: an admin
|
||||
* saving config five times in a minute must not become five sign-ins
|
||||
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
|
||||
* changed email or password starts fresh; the token itself never leaves the
|
||||
* server process.
|
||||
*/
|
||||
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
|
||||
|
||||
function json(res, code, body) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */
|
||||
function describe(err) {
|
||||
if (err instanceof OverflowError) return err.message;
|
||||
if (err && err.name === 'AbortError') return 'timed out';
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
||||
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
|
||||
}
|
||||
|
||||
function unique(list) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const v of list) {
|
||||
const k = norm(v);
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
|
||||
function destinationOf(entry) {
|
||||
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
|
||||
}
|
||||
function groupsOf(entry) {
|
||||
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
|
||||
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log, shell }) {
|
||||
/* ── config ────────────────────────────────────────────────────── */
|
||||
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||
const host = String(ep.host || '').trim();
|
||||
const port = Number(ep.port) || 1025;
|
||||
const ppPassword = String(config.password || '');
|
||||
const email = String(config.overflowEmail || '').trim();
|
||||
const ofPassword = String(config.overflowPassword || '');
|
||||
const staffNonprofit = String(config.nonprofitId || '').trim();
|
||||
const configured = Boolean(email && ofPassword);
|
||||
const alertEnabled = Boolean(config.alertEnabled);
|
||||
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
|
||||
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
|
||||
|
||||
const overflow = createOverflowClient();
|
||||
|
||||
/* ── state ─────────────────────────────────────────────────────── */
|
||||
const streams = new Set();
|
||||
const timers = new Set();
|
||||
let stopped = false;
|
||||
let lastFrame = '';
|
||||
|
||||
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
|
||||
let session = null; // { token, expiresAt, email, nonprofitId }
|
||||
let lastLoginAt = 0;
|
||||
let lastLoginError = null;
|
||||
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
|
||||
let inFlight = false;
|
||||
let retriesLeft = 0;
|
||||
let retryTimer = null;
|
||||
let alertRaised = false;
|
||||
/**
|
||||
* Refusals in a row, across keywords. The retry budget is per keyword, but
|
||||
* a service keeps bringing new keywords: if Overflow refuses every one of
|
||||
* them, each would start a fresh budget and the dot would never go red.
|
||||
* So the budget is also spent by refusals as a whole, and a success
|
||||
* clears it.
|
||||
*/
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
// Presentation layer: the slide /v1/status/slide reports.
|
||||
let lastSlideKey = null;
|
||||
let lastSlide = null;
|
||||
let lastPresKeywords = [];
|
||||
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
|
||||
let annSupported = true;
|
||||
let lastAnnKey = null;
|
||||
let annNotes = '';
|
||||
let annLive = false;
|
||||
let lastAnnKeywords = [];
|
||||
|
||||
const state = {
|
||||
status: 'connecting',
|
||||
message: '',
|
||||
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
|
||||
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
|
||||
keywords: [],
|
||||
slide: { live: false, keywords: [], hasNotes: false },
|
||||
announcement: { live: false, keywords: [] },
|
||||
current: null,
|
||||
history: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
/* ── timers that die with the handle ───────────────────────────── */
|
||||
|
||||
function later(fn, ms) {
|
||||
const t = setTimeout(() => {
|
||||
timers.delete(t);
|
||||
if (!stopped) fn();
|
||||
}, ms);
|
||||
t.unref?.();
|
||||
timers.add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
/* ── health, shared by the admin line and the tiles ────────────── */
|
||||
|
||||
function assess(forAdmin = false) {
|
||||
const pp = state.propresenter;
|
||||
const of = state.overflow;
|
||||
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
|
||||
if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
|
||||
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
|
||||
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
|
||||
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
|
||||
// A refused switch the module is still retrying on its own is amber —
|
||||
// being worked on; one it has given up on is red — the booth's problem.
|
||||
const cur = state.current;
|
||||
if (cur && !cur.ok && !cur.pending && !cur.retrying) {
|
||||
return ['error', `Tap not switched to ${cur.keyword} — ${cur.error}`];
|
||||
}
|
||||
if (cur && !cur.ok && (cur.retrying || consecutiveFailures > 0)) {
|
||||
return ['connecting', `Tap not switched to ${cur.keyword} yet — retrying (${consecutiveFailures} of ${SWITCH_RETRIES + 1} attempts failed): ${cur.error || 'in flight'}`];
|
||||
}
|
||||
const n = state.keywords.length;
|
||||
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
|
||||
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
|
||||
}
|
||||
|
||||
function publish() {
|
||||
if (stopped) return;
|
||||
[state.status, state.message] = assess(false);
|
||||
const { updatedAt, ...rest } = state;
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(rest);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
state.updatedAt = Date.now();
|
||||
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the Overflow session ──────────────────────────────────────── */
|
||||
|
||||
async function ensureSession() {
|
||||
const now = Date.now();
|
||||
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
|
||||
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
|
||||
session = sessionCache;
|
||||
return session;
|
||||
}
|
||||
if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError;
|
||||
lastLoginAt = now;
|
||||
let token;
|
||||
try {
|
||||
token = await overflow.login(email, ofPassword);
|
||||
} catch (err) {
|
||||
lastLoginError = err;
|
||||
throw err;
|
||||
}
|
||||
lastLoginError = null;
|
||||
const info = describeSession(token);
|
||||
if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role');
|
||||
let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : '');
|
||||
if (info.staff) {
|
||||
if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin');
|
||||
await overflow.selectNonprofit(token, staffNonprofit);
|
||||
nonprofitId = staffNonprofit;
|
||||
} else if (!nonprofitId) {
|
||||
nonprofitId = staffNonprofit;
|
||||
}
|
||||
if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin');
|
||||
session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId };
|
||||
sessionCache = { key: sessionKey, ...session };
|
||||
log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`);
|
||||
return session;
|
||||
}
|
||||
|
||||
function dropSession() {
|
||||
session = null;
|
||||
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
|
||||
}
|
||||
|
||||
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
|
||||
async function withSession(fn) {
|
||||
const s = await ensureSession();
|
||||
try {
|
||||
return await fn(s);
|
||||
} catch (err) {
|
||||
if (!(err instanceof OverflowError) || !err.auth) throw err;
|
||||
dropSession();
|
||||
const again = await ensureSession();
|
||||
return fn(again);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the keyword mappings ──────────────────────────────────────── */
|
||||
|
||||
function rebuildKeywordView() {
|
||||
state.keywords = [...keywordCache.values()].map((e) => ({
|
||||
keyword: String(e.keyword).trim(),
|
||||
destination: destinationOf(e),
|
||||
groups: groupsOf(e),
|
||||
}));
|
||||
state.overflow.keywords = state.keywords.length;
|
||||
}
|
||||
|
||||
/** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */
|
||||
function applyKeywordList(list, merge = false) {
|
||||
const next = merge ? new Map(keywordCache) : new Map();
|
||||
for (const entry of list || []) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const k = norm(entry.keyword);
|
||||
if (!k) continue;
|
||||
if (entry.active === false || entry.enabled === false || entry.isActive === false) {
|
||||
next.delete(k);
|
||||
continue;
|
||||
}
|
||||
next.set(k, entry);
|
||||
}
|
||||
keywordCache = next;
|
||||
rebuildKeywordView();
|
||||
}
|
||||
|
||||
let loading = null;
|
||||
let refreshTimer = null;
|
||||
|
||||
async function loadKeywords() {
|
||||
if (stopped || !configured) return;
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
try {
|
||||
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
|
||||
applyKeywordList(list);
|
||||
state.overflow.reachable = true;
|
||||
state.overflow.error = '';
|
||||
state.overflow.loadedAt = Date.now();
|
||||
reconsider(); // a slide already up when the mappings arrive takes effect now
|
||||
} catch (err) {
|
||||
state.overflow.reachable = false;
|
||||
state.overflow.error = describe(err);
|
||||
log(`Overflow: ${state.overflow.error}`);
|
||||
}
|
||||
publish();
|
||||
})();
|
||||
try {
|
||||
await loading;
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
if (stopped) return;
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
timers.delete(refreshTimer);
|
||||
}
|
||||
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
|
||||
}
|
||||
|
||||
/* ── the switch ────────────────────────────────────────────────── */
|
||||
|
||||
function describeTarget(keys) {
|
||||
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
|
||||
return {
|
||||
keywords: keys,
|
||||
keyword: keys.join(' + '),
|
||||
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
|
||||
groups: unique(entries.flatMap(groupsOf)),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(entry) {
|
||||
const { pending, ...rest } = entry;
|
||||
state.history.unshift(rest);
|
||||
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
|
||||
}
|
||||
|
||||
function raiseAlert(keyword) {
|
||||
if (!alertEnabled || !shell || typeof shell.alert !== 'function') return;
|
||||
alertRaised = true;
|
||||
Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS }))
|
||||
.catch((err) => log(`alert refused: ${describe(err)}`));
|
||||
}
|
||||
|
||||
function clearAlert() {
|
||||
if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return;
|
||||
alertRaised = false;
|
||||
Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {});
|
||||
}
|
||||
|
||||
function requestSwitch(target) {
|
||||
wanted = target;
|
||||
retriesLeft = SWITCH_RETRIES;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
timers.delete(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
runSwitch();
|
||||
}
|
||||
|
||||
async function runSwitch() {
|
||||
if (stopped || inFlight || !wanted) return;
|
||||
const target = wanted;
|
||||
const cur = state.current;
|
||||
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
|
||||
if (sameTarget && cur.ok) return; // already there
|
||||
inFlight = true;
|
||||
let entry;
|
||||
if (sameTarget) {
|
||||
entry = cur; // another go at the same switch
|
||||
} else {
|
||||
if (cur) pushHistory(cur);
|
||||
entry = {
|
||||
keyword: target.keyword,
|
||||
keywords: target.keywords,
|
||||
destination: target.destination,
|
||||
groups: target.groups,
|
||||
at: Date.now(),
|
||||
ok: false,
|
||||
pending: true,
|
||||
retrying: false, // a retry is scheduled: the module is still trying to fix this itself
|
||||
error: '',
|
||||
attempts: 0,
|
||||
};
|
||||
state.current = entry;
|
||||
}
|
||||
entry.pending = true;
|
||||
entry.attempts += 1;
|
||||
publish();
|
||||
try {
|
||||
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
|
||||
if (answer) {
|
||||
applyKeywordList(answer, true);
|
||||
const fresh = describeTarget(target.keywords);
|
||||
entry.destination = fresh.destination || entry.destination;
|
||||
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
|
||||
}
|
||||
entry.ok = true;
|
||||
entry.error = '';
|
||||
entry.retrying = false;
|
||||
entry.at = Date.now();
|
||||
consecutiveFailures = 0;
|
||||
clearAlert();
|
||||
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
|
||||
} catch (err) {
|
||||
if (stopped) return;
|
||||
entry.ok = false;
|
||||
entry.error = describe(err);
|
||||
consecutiveFailures += 1;
|
||||
if (retriesLeft > 0 && consecutiveFailures <= SWITCH_RETRIES) {
|
||||
// Still the module's problem: try again shortly; the dot goes amber.
|
||||
retriesLeft -= 1;
|
||||
entry.retrying = true;
|
||||
log(`Tap not switched to ${target.keyword}: ${entry.error} — retrying in ${SWITCH_RETRY_MS / 1000} s`);
|
||||
retryTimer = later(() => {
|
||||
retryTimer = null;
|
||||
runSwitch();
|
||||
}, SWITCH_RETRY_MS);
|
||||
} else {
|
||||
// Out of retries: now it is the booth's problem — red dot, and the
|
||||
// alert if the operator asked for one (raised once per outage, not
|
||||
// again for every further keyword Overflow refuses).
|
||||
entry.retrying = false;
|
||||
log(`Tap not switched to ${target.keyword}: ${entry.error} — giving up after ${consecutiveFailures} refusals`);
|
||||
if (!alertRaised) raiseAlert(target.keyword);
|
||||
}
|
||||
} finally {
|
||||
entry.pending = false;
|
||||
inFlight = false;
|
||||
publish();
|
||||
if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile
|
||||
}
|
||||
}
|
||||
|
||||
/* ── what is on screen, and what to do about it ────────────────── */
|
||||
|
||||
/**
|
||||
* The helper's rule, per layer: when the set of keywords the layer's
|
||||
* slide carries changes to a non-empty set, send it. Returning to a
|
||||
* keyword after a slide without one sends it again; staying on the same
|
||||
* keyword across slides sends nothing.
|
||||
*/
|
||||
function reconsider() {
|
||||
const list = [...keywordCache.values()];
|
||||
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
|
||||
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
|
||||
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
|
||||
const presChanged = !sameSet(presKeys, lastPresKeywords);
|
||||
if (presChanged) lastPresKeywords = presKeys;
|
||||
|
||||
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
|
||||
state.announcement = { live: annLive, keywords: annKeys };
|
||||
const annChanged = !sameSet(annKeys, lastAnnKeywords);
|
||||
if (annChanged) lastAnnKeywords = annKeys;
|
||||
|
||||
const batch = [];
|
||||
if (presChanged && presKeys.length) batch.push(...presKeys);
|
||||
if (annChanged && annKeys.length) batch.push(...annKeys);
|
||||
if (batch.length) requestSwitch(describeTarget(unique(batch)));
|
||||
}
|
||||
|
||||
/* ── ProPresenter ──────────────────────────────────────────────── */
|
||||
|
||||
function ppUrl(target) {
|
||||
const u = new URL(`http://${host}:${port}${target}`);
|
||||
if (ppPassword) u.searchParams.set('password', ppPassword);
|
||||
return u;
|
||||
}
|
||||
|
||||
async function ppJson(target) {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
|
||||
if (!res.ok) {
|
||||
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
|
||||
async function pollSlide() {
|
||||
const data = await ppJson('/v1/status/slide');
|
||||
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
|
||||
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
|
||||
if (key === lastSlideKey) return false;
|
||||
lastSlideKey = key;
|
||||
lastSlide = cur;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The announcement layer: its slide index names the document and the
|
||||
* slide; the document's slides (every group's, in order, disabled ones
|
||||
* included — that is the index's space) carry the notes. An older
|
||||
* ProPresenter without this API answers 404 once and is left alone.
|
||||
*/
|
||||
async function pollAnnouncement() {
|
||||
if (!annSupported) return false;
|
||||
let idx;
|
||||
try {
|
||||
idx = await ppJson('/v1/announcement/slide_index');
|
||||
} catch (err) {
|
||||
if (err && err.status === 404) {
|
||||
annSupported = false;
|
||||
log('this ProPresenter has no announcement layer API — following the presentation layer only');
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const node = idx && idx.announcement_index;
|
||||
if (!node || !Number.isInteger(node.index)) {
|
||||
if (lastAnnKey === '') return false;
|
||||
lastAnnKey = '';
|
||||
annLive = false;
|
||||
annNotes = '';
|
||||
return true;
|
||||
}
|
||||
const id = node.presentation_id || {};
|
||||
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
|
||||
if (key === lastAnnKey) return false;
|
||||
const doc = await ppJson('/v1/announcement/active');
|
||||
const ann = doc && doc.announcement;
|
||||
const slides = [];
|
||||
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
|
||||
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
|
||||
}
|
||||
const slide = slides[node.index];
|
||||
annNotes = slide ? cleanNotes(slide.notes) : '';
|
||||
annLive = true;
|
||||
lastAnnKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pollTick() {
|
||||
if (stopped) return;
|
||||
let ok = false;
|
||||
let changed = false;
|
||||
try {
|
||||
changed = await pollSlide();
|
||||
state.propresenter.reachable = true;
|
||||
state.propresenter.error = '';
|
||||
ok = true;
|
||||
try {
|
||||
if (await pollAnnouncement()) changed = true;
|
||||
} catch {
|
||||
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
|
||||
}
|
||||
} catch (err) {
|
||||
state.propresenter.reachable = false;
|
||||
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
|
||||
}
|
||||
if (changed) reconsider();
|
||||
publish();
|
||||
later(pollTick, ok ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
/* ── go ────────────────────────────────────────────────────────── */
|
||||
|
||||
if (host) {
|
||||
pollTick();
|
||||
log(`following ProPresenter at ${host}:${port}`);
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
if (configured) {
|
||||
loadKeywords();
|
||||
log(`Overflow at ${overflow.base}`);
|
||||
} else {
|
||||
log('Overflow login not set — add the email and password in /admin');
|
||||
}
|
||||
publish();
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
const handle = {
|
||||
streams,
|
||||
configured,
|
||||
getState: () => state,
|
||||
refresh: () => loadKeywords(),
|
||||
};
|
||||
current = handle;
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(heartbeat);
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers.clear();
|
||||
clearAlert();
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current === handle) current = null;
|
||||
},
|
||||
health() {
|
||||
const [status, message] = assess(true);
|
||||
return { status, message };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||
json(res, 200, { state: current.getState() });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
if (!current) return void res.end();
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`);
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
current.streams.add(res);
|
||||
req.on('close', () => current && current.streams.delete(res));
|
||||
},
|
||||
|
||||
'POST /refresh': async (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||
if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' });
|
||||
await current.refresh();
|
||||
const st = current.getState();
|
||||
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
|
||||
json(res, 200, { ok: true, keywords: st.overflow.keywords });
|
||||
},
|
||||
|
||||
'GET /keywords': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||
const st = current.getState();
|
||||
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and
|
||||
styled with the shell's variables only. Sizes hang off the tile (container
|
||||
units) so the keyword reads from across a booth on a big tile and still
|
||||
fits a 2×2. */
|
||||
|
||||
[data-module="overflow-tap"] {
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: clamp(4px, 4cqmin, 18px);
|
||||
gap: clamp(2px, 1.8cqmin, 10px);
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-offline {
|
||||
flex-shrink: 0;
|
||||
font-size: clamp(0.6rem, 4.6cqmin, 0.9rem);
|
||||
font-weight: 700;
|
||||
color: var(--danger);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-root.is-offline .ot-main,
|
||||
[data-module="overflow-tap"] .ot-root.is-offline .ot-slide,
|
||||
[data-module="overflow-tap"] .ot-root.is-offline .ot-history {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
/* ── the live keyword ───────────────────────────────────────────── */
|
||||
|
||||
[data-module="overflow-tap"] .ot-main {
|
||||
--tone: var(--accent);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); }
|
||||
|
||||
[data-module="overflow-tap"] .ot-keyword {
|
||||
color: var(--tone);
|
||||
font-size: min(clamp(1rem, 44cqh, 7rem), 18cqw);
|
||||
font-weight: 750;
|
||||
line-height: 1.15;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword {
|
||||
animation: ot-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-dest {
|
||||
color: var(--text);
|
||||
font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; }
|
||||
|
||||
[data-module="overflow-tap"] .ot-groups {
|
||||
color: var(--muted);
|
||||
font-size: clamp(0.5rem, 9cqh, 0.95rem);
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── this slide ─────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="overflow-tap"] .ot-slide {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
font-size: clamp(0.55rem, 4.8cqmin, 0.95rem);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0.25em 0.6em;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text) 7%, transparent);
|
||||
align-self: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); }
|
||||
[data-module="overflow-tap"] .ot-slide.is-hit {
|
||||
color: var(--accent-text);
|
||||
background: color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
font-weight: 650;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-slide.is-warn {
|
||||
color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 16%, transparent);
|
||||
}
|
||||
|
||||
/* ── the trail ──────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="overflow-tap"] .ot-history {
|
||||
flex-shrink: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3em 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: clamp(0.55rem, 4.6cqmin, 0.95rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1em;
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-h-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0 0.7em;
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw,
|
||||
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); }
|
||||
[data-module="overflow-tap"] .ot-h-time {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; }
|
||||
[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; }
|
||||
|
||||
/* On a short tile the trail would crowd the keyword out: keep the
|
||||
keyword, drop the trail. Inside the keyword block the same rule: when
|
||||
it is short, the small lines go before the keyword does. */
|
||||
@container (max-height: 120px) {
|
||||
[data-module="overflow-tap"] .ot-history { display: none; }
|
||||
}
|
||||
@container (max-height: 70px) {
|
||||
[data-module="overflow-tap"] .ot-groups { display: none; }
|
||||
}
|
||||
@container (max-height: 46px) {
|
||||
[data-module="overflow-tap"] .ot-dest { display: none; }
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6%;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
font-size: clamp(0.65rem, 5.5cqmin, 1rem);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
|
||||
* the overflow-tap module without a live ProPresenter or an Overflow account.
|
||||
*
|
||||
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
|
||||
* [--login=<email>:<password>] [--expire=<seconds>]
|
||||
* [--fail-switch] [--slow] [--no-announcements]
|
||||
*
|
||||
* ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
|
||||
* with a service that advances every --step ms:
|
||||
* GET /version reachability probe
|
||||
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
|
||||
* through slides whose notes carry keywords
|
||||
* ("give", "Next steps: connect", "prayer"),
|
||||
* slides with other notes ("forgiveness" —
|
||||
* must NOT fire "give"), a slide whose
|
||||
* keyword is not mapped on the account, and
|
||||
* a stretch with nothing live.
|
||||
* GET /v1/announcement/slide_index the announcement layer: a three-slide
|
||||
* document whose index advances every third
|
||||
* step; its middle slide carries "events".
|
||||
* GET /v1/announcement/active that document, groups → slides → notes.
|
||||
* --no-announcements answers 404 to both,
|
||||
* like an older ProPresenter.
|
||||
*
|
||||
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
|
||||
* module speaks (see modules/overflow-tap/overflow.js):
|
||||
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
|
||||
* password signs in (--login=<email>:<password> demands that pair,
|
||||
* answering 401 otherwise); the token expires after --expire seconds
|
||||
* (3600 by default — set 20 to exercise the sign-in-again path). An
|
||||
* @overflow.co email gets a staff token with no nonprofit of its own.
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
|
||||
* give → Give Online (every group), connect → Connect Card (Lobby),
|
||||
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
|
||||
* Wing). "baptism" is deliberately unmapped.
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
|
||||
* { keywords } — moves the groups and answers the updated list.
|
||||
* --fail-switch answers 500 instead, to exercise the failure path.
|
||||
* Every signed-in call needs a Bearer token this mock issued and has not
|
||||
* expired, or it answers 401. --slow adds a two-second delay to every Overflow
|
||||
* answer.
|
||||
*
|
||||
* Every incoming request is logged so we can confirm what the module does.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (name, dflt) => {
|
||||
const a = args.find((x) => x.startsWith(`--${name}=`));
|
||||
return a ? a.slice(name.length + 3) : dflt;
|
||||
};
|
||||
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
|
||||
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
|
||||
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
|
||||
const LOGIN = opt('login', '');
|
||||
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
|
||||
const FAIL_SWITCH = args.includes('--fail-switch');
|
||||
const SLOW = args.includes('--slow');
|
||||
const NO_ANN = args.includes('--no-announcements');
|
||||
|
||||
function sendJson(res, code, body, extra = {}) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
|
||||
res.end(body === undefined ? '' : JSON.stringify(body));
|
||||
}
|
||||
|
||||
/* ── ProPresenter ────────────────────────────────────────────────── */
|
||||
|
||||
const SLIDES = [
|
||||
{ uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
|
||||
{ uuid: 'S2', text: 'Give online', notes: 'give' },
|
||||
{ uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
|
||||
{ uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
|
||||
{ uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
|
||||
{ uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
|
||||
{ uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
|
||||
null, // nothing live
|
||||
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
|
||||
{ uuid: 'S9', text: 'See you next week', notes: '' },
|
||||
];
|
||||
const ANNOUNCEMENT = {
|
||||
id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
|
||||
presentation_path: 'Announcements',
|
||||
groups: [
|
||||
{ name: 'Loop', slides: [
|
||||
{ enabled: true, notes: '', text: 'Welcome', label: '' },
|
||||
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
|
||||
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
|
||||
] },
|
||||
],
|
||||
};
|
||||
let step = 0;
|
||||
setInterval(() => { step += 1; }, STEP_MS).unref();
|
||||
const slideIdx = () => step % SLIDES.length;
|
||||
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
|
||||
|
||||
function slidePayload() {
|
||||
const cur = SLIDES[slideIdx()];
|
||||
const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
|
||||
return {
|
||||
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
|
||||
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
|
||||
};
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const p = url.pathname;
|
||||
console.log(`[pp] ${req.method} ${p}`);
|
||||
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
|
||||
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
|
||||
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN });
|
||||
if (p === '/v1/announcement/slide_index') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
|
||||
}
|
||||
if (p === '/v1/announcement/active') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
|
||||
}
|
||||
sendJson(res, 404, { error: 'not found' });
|
||||
}).listen(PP_PORT, '127.0.0.1', () => {
|
||||
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
|
||||
});
|
||||
|
||||
/* ── Overflow Dashboard ──────────────────────────────────────────── */
|
||||
|
||||
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
|
||||
const DESTINATIONS = {
|
||||
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
|
||||
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
|
||||
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
|
||||
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
|
||||
};
|
||||
const GROUPS = [
|
||||
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
|
||||
];
|
||||
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
|
||||
const KEYWORDS = [
|
||||
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
|
||||
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
|
||||
];
|
||||
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
|
||||
|
||||
const tokens = new Map(); // token → { email, exp, nonprofitId }
|
||||
|
||||
function issueToken(email) {
|
||||
const staff = email.toLowerCase().endsWith('@overflow.co');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
id: 'user-1',
|
||||
email,
|
||||
fullName: 'Mock User',
|
||||
role: staff ? 'overflowSupport' : 'orgAdmin',
|
||||
permissions: [{ action: 'manage', subject: 'TAP' }],
|
||||
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
|
||||
iat: now,
|
||||
exp: now + EXPIRE_S,
|
||||
};
|
||||
if (!staff) payload.nonprofitId = NONPROFIT.id;
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
|
||||
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
function bearer(req) {
|
||||
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
|
||||
if (!m) return null;
|
||||
const t = tokens.get(m[1]);
|
||||
if (!t) return null;
|
||||
if (Date.now() / 1000 >= t.exp) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const p = url.pathname;
|
||||
const answer = (code, body) => {
|
||||
const go = () => sendJson(res, code, body);
|
||||
if (SLOW) setTimeout(go, 2000);
|
||||
else go();
|
||||
};
|
||||
console.log(`[overflow] ${req.method} ${p}${url.search}`);
|
||||
|
||||
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
|
||||
const body = await readBody(req);
|
||||
const email = String((body && body.email) || '').trim();
|
||||
const password = String((body && body.password) || '');
|
||||
if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
|
||||
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
|
||||
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
|
||||
return answer(200, { token: issueToken(email) });
|
||||
}
|
||||
|
||||
const session = bearer(req);
|
||||
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
|
||||
|
||||
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
|
||||
return answer(200, { data: { nonprofits: [NONPROFIT] } });
|
||||
}
|
||||
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
|
||||
if (req.method === 'POST' && sel) {
|
||||
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
|
||||
session.nonprofitId = NONPROFIT.id;
|
||||
return answer(204);
|
||||
}
|
||||
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
|
||||
if (kw) {
|
||||
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
|
||||
if (req.method === 'GET') return answer(200, stampedKeywords());
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
|
||||
if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
|
||||
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
|
||||
for (const k of keys) {
|
||||
const entry = KEYWORDS.find((x) => x.keyword === k);
|
||||
if (!entry) {
|
||||
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
|
||||
continue;
|
||||
}
|
||||
for (const g of entry.tapGroups) {
|
||||
const group = GROUPS.find((x) => x.id === g.id);
|
||||
if (group) group.destinationId = entry.destinationId;
|
||||
}
|
||||
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
|
||||
}
|
||||
return answer(200, stampedKeywords());
|
||||
}
|
||||
}
|
||||
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
|
||||
}).listen(OF_PORT, '127.0.0.1', () => {
|
||||
console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
# ProdDash Viewer for macOS
|
||||
|
||||
A Mac app whose only job is to show ProdDash. It opens one installation's
|
||||
dashboard in its own window — or several, with a slim tab bar to switch
|
||||
between them — and does nothing else: no address bar, no bookmarks, no
|
||||
browser to wander off in. It is for the screens that just *show* a dashboard:
|
||||
a booth display, a stage-manager's laptop, a second Mac watching the other
|
||||
campus.
|
||||
|
||||
It runs no server. ProdDash itself is installed with the menu-bar app in
|
||||
[launcher/macos](../../launcher/macos); the Viewer points at that, or at any
|
||||
other ProdDash on the network.
|
||||
|
||||
Apple silicon, macOS 13 or newer. Native Swift (AppKit with a little SwiftUI,
|
||||
WebKit for the page), no dependencies, about 1 MB.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./build.sh # build/ProdDash Viewer.app
|
||||
./build.sh --dmg # …and build/ProdDash-Viewer-<version>-arm64.dmg
|
||||
./build.sh --install # …and copy it to /Applications
|
||||
./build.sh --run # …and open it
|
||||
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||
```
|
||||
|
||||
Needs Xcode, selected with `xcode-select` — with only the command line
|
||||
tools, the macOS 27 SDK can't expand SwiftUI's `@State` macro and the
|
||||
compile fails. Signing and notarising work exactly as
|
||||
for ProdDash's own app — a Developer ID certificate when one is installed,
|
||||
ad-hoc otherwise, and the same `proddash` notarytool profile; see
|
||||
[launcher/macos/README.md](../../launcher/macos/README.md#signing). The
|
||||
Viewer keeps its own version number (`VERSION` in `build.sh`), since it works
|
||||
with any ProdDash rather than shipping with one.
|
||||
|
||||
The icon is `Resources/AppIcon.png`, drawn by `Resources/make-icon.swift` —
|
||||
ProdDash's tile mark on a display, so the two apps tell apart in Finder.
|
||||
Run `swift Resources/make-icon.swift Resources/AppIcon.png` to redraw it.
|
||||
|
||||
## Using it
|
||||
|
||||
**Settings…** (⌘,) → **Connections** → **+**: a name (what the tab says) and
|
||||
the address you open ProdDash at in a browser, such as `10.3.11.152:24500`.
|
||||
Without a port, ProdDash's 24500 is assumed; a path is kept, so an address
|
||||
can open a particular page. The sheet asks the address whether ProdDash is
|
||||
answering as you type, but saves either way — the installation may simply be
|
||||
switched off right now. Double-click a connection to change it, drag to
|
||||
reorder the tabs.
|
||||
|
||||
With two or more connections the tab bar appears. In a window it floats over
|
||||
the top of the dashboard, comes out while the pointer is on the title bar and
|
||||
tucks away after; in full screen it comes down with the menu bar, and shows
|
||||
for a moment after each switch. ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 switch from the
|
||||
keyboard. A tab marked ⚠︎ is an installation that isn't answering.
|
||||
|
||||
The menus are short on purpose: **View** has Back (⌘[), Forward (⌘]), Reload
|
||||
Page (⌘R) and Enter Full Screen; the rest is what every Mac app has. **Edit**
|
||||
is there because without it Copy and Paste do nothing in the dashboard's and
|
||||
the admin page's text fields. **Help** opens the guide that ships in the app.
|
||||
|
||||
**Settings → General**: **Open at login**, and **Keep the display awake**
|
||||
(on by default) — while a dashboard is on screen, the display doesn't dim or
|
||||
sleep. Minimised, hidden or covered, it lets go.
|
||||
|
||||
## How it behaves
|
||||
|
||||
- **Every dashboard stays live.** Each connection has its own web view, kept
|
||||
running behind the one on screen, so switching is instant and nothing has
|
||||
to reconnect.
|
||||
- **The page never jumps.** In a window the tab bar is an overlay rather than
|
||||
part of the title bar: anything in the title bar makes WebKit push the
|
||||
whole page down to clear it, and a dashboard re-flowing every time the bar
|
||||
came and went would be worse than no bar. In full screen it *is* a title
|
||||
bar accessory — there the title bar lives in a window of its own, and the
|
||||
page stays put.
|
||||
- **It comes back by itself.** An installation that can't be reached shows
|
||||
why (nothing answering, can't be found, no network — including macOS
|
||||
withholding Local Network access) and is retried at 2, 4, 8 and then every
|
||||
15 seconds. Once a dashboard has loaded, ProdDash's own service worker and
|
||||
reconnect logic carry it through a server restart.
|
||||
- **It comes back as it was left.** The window's size and place, the tab that
|
||||
was showing, and full screen: quit in full screen and it reopens in full
|
||||
screen — so a booth Mac set to log in and open the Viewer at login returns
|
||||
to the dashboard on its own after a restart.
|
||||
- **It stays on ProdDash.** Links to the installation's own pages open in
|
||||
the window; links anywhere else (a Planning Center item, the release notes)
|
||||
open in the default browser, as do downloads. The page's `alert`,
|
||||
`confirm` and `prompt` — the admin page confirms before deleting a layout,
|
||||
Band Lineup prompts for names — show as sheets titled with the
|
||||
installation's name.
|
||||
- **The dashboard's own full-screen button works**, in addition to the
|
||||
window's: it fills the screen with the dashboard alone; Esc leaves.
|
||||
- Closing the window quits the app, as with System Settings — there is only
|
||||
ever the one window.
|
||||
- Safari's **Develop** menu can inspect a dashboard running in the Viewer.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
Sources/
|
||||
main.swift NSApplication, nothing else
|
||||
AppDelegate.swift lifecycle and the menus
|
||||
ViewerWindowController.swift the window: pages, tab bar overlay/accessory,
|
||||
full screen, keyboard switching, display awake
|
||||
DashboardPage.swift one connection's web view, status view, retry,
|
||||
navigation policy, JavaScript dialogs
|
||||
TabBar.swift the tabs (SwiftUI), shared by overlay and accessory
|
||||
Connections.swift Connection, address rules, the store, the probe
|
||||
ViewerSettings.swift preferences and the login item
|
||||
SettingsWindowController.swift Settings: Connections and General
|
||||
HelpWindowController.swift Help → ProdDash Viewer Help
|
||||
Resources/
|
||||
Info.plist bundle id org.waterschurch.proddash.viewer
|
||||
Help/index.html the guide shown by the Help menu
|
||||
AppIcon.png, make-icon.swift the icon and what draws it
|
||||
```
|
||||
|
||||
Preferences live in `~/Library/Preferences/org.waterschurch.proddash.viewer.plist`
|
||||
(connections, the selected tab, full screen, keep awake); the web views'
|
||||
storage — each dashboard's own per-browser settings — in WebKit's default
|
||||
store for the app.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
@@ -1,78 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProdDash Viewer Help</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; --muted: #6e6e73; --rule: #d2d2d7; --key: #f2f2f7; }
|
||||
@media (prefers-color-scheme: dark) { :root { --muted: #98989d; --rule: #3a3a3c; --key: #2c2c2e; } }
|
||||
body { font: 13px/1.55 -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 28px 36px 48px; max-width: 640px; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; margin: 28px 0 6px; padding-top: 18px; border-top: 1px solid var(--rule); }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 20px; }
|
||||
.lead { color: var(--muted); margin-bottom: 18px; }
|
||||
kbd { font: 12px -apple-system, sans-serif; background: var(--key); border: 1px solid var(--rule); border-radius: 4px; padding: 0 5px; }
|
||||
code { font: 12px ui-monospace, Menlo, monospace; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
|
||||
td { padding: 5px 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
||||
td:first-child { width: 42%; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>ProdDash Viewer</h1>
|
||||
<p class="lead">A window onto ProdDash. It shows one installation’s dashboard — or several, with a tab for each — and nothing else.</p>
|
||||
|
||||
<h2>Add an installation</h2>
|
||||
<ul>
|
||||
<li>Choose <b>ProdDash Viewer → Settings…</b> (<kbd>⌘,</kbd>), then <b>Connections</b>, then <b>+</b>.</li>
|
||||
<li><b>Name</b> is what its tab says — “AB Booth”, “NA Ingest”.</li>
|
||||
<li><b>Address</b> is the one you open ProdDash at in a browser, such as <code>10.3.11.152:24500</code>. Leave the port off and ProdDash’s own, 24500, is used. The sheet tells you as you type whether ProdDash is answering there.</li>
|
||||
<li>Double-click a connection to change it; drag connections to change the order of the tabs.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Switch between installations</h2>
|
||||
<p>With two or more connections, a slim tab bar of their names sits under the title bar. It tucks itself away so the dashboard has the whole window: point at the title bar and it slides back out.</p>
|
||||
<ul>
|
||||
<li>In full screen, move the pointer to the top of the screen — the tab bar comes down with the menu bar. It also shows for a moment after each switch, so you can see where you are.</li>
|
||||
<li><kbd>⌃Tab</kbd> and <kbd>⌃⇧Tab</kbd> go to the next and previous installation; <kbd>⌘1</kbd>–<kbd>⌘8</kbd> go straight to one, <kbd>⌘9</kbd> to the last.</li>
|
||||
<li>Every dashboard stays live in the background, so switching is instant.</li>
|
||||
<li>A tab with <b>⚠︎</b> is an installation that isn’t answering.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Full screen</h2>
|
||||
<p>Click the green button, or choose <b>View → Enter Full Screen</b> (<kbd>fn F</kbd> or <kbd>⌃⌘F</kbd>). If the Viewer was in full screen when it quit, it opens in full screen again — so a booth Mac that restarts comes back as it was left.</p>
|
||||
<p>The dashboard’s own full-screen button works too: it fills the screen with the dashboard alone, tab bar and all put away. Press <kbd>Esc</kbd> to leave it.</p>
|
||||
|
||||
<h2>Moving around</h2>
|
||||
<ul>
|
||||
<li><b>View → Back</b> (<kbd>⌘[</kbd>), <b>Forward</b> (<kbd>⌘]</kbd>) — after opening the admin page from the dashboard, for example. Swiping with two fingers works as in Safari.</li>
|
||||
<li><b>View → Reload Page</b> (<kbd>⌘R</kbd>).</li>
|
||||
<li>Links to anywhere outside the installation open in your web browser.</li>
|
||||
</ul>
|
||||
|
||||
<h2>When an installation can’t be reached</h2>
|
||||
<p>The Viewer says why — nothing answering, the address can’t be found, no network — and keeps trying by itself, so a dashboard comes back on its own after ProdDash restarts or its Mac reboots. <b>Try Again</b> asks right away.</p>
|
||||
<p>If macOS asked whether ProdDash Viewer may find devices on your local network and the answer was no, it can’t reach anything: allow it in <b>System Settings → Privacy & Security → Local Network</b>.</p>
|
||||
|
||||
<h2>General settings</h2>
|
||||
<ul>
|
||||
<li><b>Open at login</b> — start the Viewer when this Mac’s user logs in. macOS may ask you to approve it in System Settings first.</li>
|
||||
<li><b>Keep the display awake</b> — while a dashboard is on screen, the display doesn’t dim or sleep. On unless you turn it off.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<table>
|
||||
<tr><td><kbd>⌘,</kbd></td><td>Settings</td></tr>
|
||||
<tr><td><kbd>⌘[</kbd> <kbd>⌘]</kbd></td><td>Back, Forward</td></tr>
|
||||
<tr><td><kbd>⌘R</kbd></td><td>Reload Page</td></tr>
|
||||
<tr><td><kbd>fn F</kbd> <kbd>⌃⌘F</kbd></td><td>Enter or leave full screen</td></tr>
|
||||
<tr><td><kbd>⌃Tab</kbd> <kbd>⌃⇧Tab</kbd></td><td>Next, previous installation</td></tr>
|
||||
<tr><td><kbd>⌘1</kbd> … <kbd>⌘9</kbd></td><td>An installation by its place in the tab bar</td></tr>
|
||||
<tr><td><kbd>⌘W</kbd></td><td>Close the window, which quits the Viewer</td></tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>ProdDash Viewer</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ProdDash Viewer</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ProdDashViewer</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.waterschurch.proddash.viewer</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 Waters Church</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSArchitecturePriority</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<!-- ProdDash is served over plain http on the local network, and the
|
||||
connection sheet asks each address whether ProdDash is there. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<!-- macOS shows this sentence verbatim when it asks. -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>ProdDash Viewer shows the dashboards of the ProdDash installations you add in Settings, which run on this network.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,78 +0,0 @@
|
||||
// Draws AppIcon.png, the Viewer's icon: ProdDash's 2×2 tile mark on a
|
||||
// display, so the Viewer and ProdDash itself tell apart in Finder and the
|
||||
// Dock. Run it again only to change the icon; build.sh uses the PNG.
|
||||
//
|
||||
// swift Resources/make-icon.swift Resources/AppIcon.png
|
||||
import AppKit
|
||||
|
||||
let side = 1024
|
||||
let output = CommandLine.arguments.dropFirst().first ?? "AppIcon.png"
|
||||
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
|
||||
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0, bitsPerPixel: 0),
|
||||
let context = NSGraphicsContext(bitmapImageRep: bitmap) else { fatalError("no bitmap") }
|
||||
NSGraphicsContext.current = context
|
||||
let g = context.cgContext
|
||||
// Top-left origin, as the measurements below are written.
|
||||
g.translateBy(x: 0, y: CGFloat(side))
|
||||
g.scaleBy(x: 1, y: -1)
|
||||
|
||||
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
|
||||
CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255, green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
|
||||
}
|
||||
func rounded(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
|
||||
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
||||
}
|
||||
func fill(_ path: CGPath, _ fillColor: CGColor) {
|
||||
g.addPath(path); g.setFillColor(fillColor); g.fillPath()
|
||||
}
|
||||
|
||||
// The body: Apple's icon grid — an 824-point rounded square, centred, on the
|
||||
// soft shadow macOS icons sit on. ProdDash's own near-black.
|
||||
let body = CGRect(x: 100, y: 100, width: 824, height: 824)
|
||||
let bodyPath = rounded(body, 185)
|
||||
g.saveGState()
|
||||
g.setShadow(offset: CGSize(width: 0, height: 12), blur: 28, color: color(0x000000, 0.35))
|
||||
fill(bodyPath, color(0x0b0e12))
|
||||
g.restoreGState()
|
||||
g.saveGState()
|
||||
g.addPath(bodyPath); g.clip()
|
||||
let shade = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB),
|
||||
colors: [color(0x1b222b), color(0x0a0d11)] as CFArray, locations: [0, 1])!
|
||||
g.drawLinearGradient(shade, start: CGPoint(x: 0, y: body.minY), end: CGPoint(x: 0, y: body.maxY), options: [])
|
||||
g.restoreGState()
|
||||
g.addPath(rounded(body.insetBy(dx: 1.5, dy: 1.5), 183.5))
|
||||
g.setStrokeColor(color(0xffffff, 0.08)); g.setLineWidth(3); g.strokePath()
|
||||
|
||||
// The display: bezel, screen, neck and foot.
|
||||
let bezel = CGRect(x: 212, y: 250, width: 600, height: 410)
|
||||
let metal = color(0x3a434f)
|
||||
fill(rounded(CGRect(x: 474, y: 650, width: 76, height: 84), 8), metal)
|
||||
fill(rounded(CGRect(x: 372, y: 724, width: 280, height: 36), 18), color(0x4a5462))
|
||||
fill(rounded(bezel, 44), metal)
|
||||
let screen = bezel.insetBy(dx: 22, dy: 22)
|
||||
fill(rounded(screen, 26), color(0x0c1015))
|
||||
|
||||
// ProdDash's mark on the screen: three outlined tiles and the lit one.
|
||||
let padding: CGFloat = 46, gap: CGFloat = 30
|
||||
let tileW = (screen.width - padding * 2 - gap) / 2
|
||||
let tileH = (screen.height - padding * 2 - gap) / 2
|
||||
for (column, row) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let tile = CGRect(x: screen.minX + padding + CGFloat(column) * (tileW + gap),
|
||||
y: screen.minY + padding + CGFloat(row) * (tileH + gap),
|
||||
width: tileW, height: tileH)
|
||||
if column == 1 && row == 1 {
|
||||
fill(rounded(tile, 22), color(0x2fe39a))
|
||||
} else {
|
||||
let inset = tile.insetBy(dx: 5, dy: 5)
|
||||
fill(rounded(inset, 18), color(0x121820))
|
||||
g.addPath(rounded(inset, 18)); g.setStrokeColor(color(0x3d4652)); g.setLineWidth(10); g.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else { fatalError("no png") }
|
||||
try png.write(to: URL(fileURLWithPath: output))
|
||||
print("wrote \(output)")
|
||||
@@ -1,110 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
/**
|
||||
ProdDash Viewer: one window showing a ProdDash dashboard, a tab per
|
||||
installation when there's more than one, and Settings to say which.
|
||||
|
||||
The menus are deliberately short — Back, Forward, Reload Page and Settings,
|
||||
plus what every Mac app has: the app menu, Edit (without which Copy and
|
||||
Paste do nothing in the dashboard's text fields), Window and Help.
|
||||
*/
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
static var version: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
}
|
||||
|
||||
private var viewer: ViewerWindowController!
|
||||
private lazy var settingsWindow = SettingsWindowController()
|
||||
private lazy var helpWindow = HelpWindowController()
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
NSApp.mainMenu = makeMainMenu()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
viewer = ViewerWindowController()
|
||||
viewer.onAddConnection = { [weak self] in self?.settingsWindow.show(addingConnection: true) }
|
||||
viewer.showWindow(nil)
|
||||
if ViewerSettings.shared.reopenInFullScreen {
|
||||
DispatchQueue.main.async { self.viewer.window?.toggleFullScreen(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if !flag { viewer.showWindow(nil) }
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
viewer?.rememberFullScreen()
|
||||
}
|
||||
|
||||
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
|
||||
|
||||
@objc func showSettings(_ sender: Any?) { settingsWindow.show() }
|
||||
@objc func showViewerHelp(_ sender: Any?) { helpWindow.show() }
|
||||
|
||||
// MARK: - Menus
|
||||
|
||||
private func makeMainMenu() -> NSMenu {
|
||||
let name = "ProdDash Viewer"
|
||||
let main = NSMenu()
|
||||
|
||||
let app = submenu(of: main, title: name)
|
||||
app.addItem(withTitle: "About \(name)",
|
||||
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
|
||||
app.addItem(.separator())
|
||||
app.addItem(withTitle: "Settings…", action: #selector(showSettings(_:)), keyEquivalent: ",")
|
||||
app.addItem(.separator())
|
||||
let services = submenu(of: app, title: "Services")
|
||||
NSApp.servicesMenu = services
|
||||
app.addItem(.separator())
|
||||
app.addItem(withTitle: "Hide \(name)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||
app.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)),
|
||||
keyEquivalent: "h").keyEquivalentModifierMask = [.command, .option]
|
||||
app.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
|
||||
app.addItem(.separator())
|
||||
app.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
|
||||
let edit = submenu(of: main, title: "Edit")
|
||||
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
|
||||
edit.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
|
||||
edit.addItem(.separator())
|
||||
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||
|
||||
let view = submenu(of: main, title: "View")
|
||||
view.addItem(withTitle: "Back", action: #selector(ViewerWindowController.goBackInPage(_:)), keyEquivalent: "[")
|
||||
view.addItem(withTitle: "Forward", action: #selector(ViewerWindowController.goForwardInPage(_:)), keyEquivalent: "]")
|
||||
view.addItem(withTitle: "Reload Page", action: #selector(ViewerWindowController.reloadPage(_:)), keyEquivalent: "r")
|
||||
view.addItem(.separator())
|
||||
// AppKit retitles this "Exit Full Screen" while the window is in full screen.
|
||||
view.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)),
|
||||
keyEquivalent: "f").keyEquivalentModifierMask = [.command, .control]
|
||||
|
||||
let window = submenu(of: main, title: "Window")
|
||||
window.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||
window.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
|
||||
window.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
|
||||
window.addItem(.separator())
|
||||
window.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
|
||||
NSApp.windowsMenu = window
|
||||
|
||||
let help = submenu(of: main, title: "Help")
|
||||
help.addItem(withTitle: "\(name) Help", action: #selector(showViewerHelp(_:)), keyEquivalent: "?")
|
||||
NSApp.helpMenu = help
|
||||
|
||||
return main
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func submenu(of parent: NSMenu, title: String) -> NSMenu {
|
||||
let menu = NSMenu(title: title)
|
||||
parent.addItem(withTitle: title, action: nil, keyEquivalent: "").submenu = menu
|
||||
return menu
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// One ProdDash installation the viewer shows: the name its tab carries and
|
||||
/// the address it answers at.
|
||||
struct Connection: Codable, Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
/// Always a full http(s) URL — see `Address.normalize`.
|
||||
var address: String
|
||||
|
||||
var url: URL? { URL(string: address) }
|
||||
|
||||
/// The name, or the host when nobody gave it one.
|
||||
var displayName: String {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed.isEmpty ? (url?.host ?? address) : trimmed
|
||||
}
|
||||
|
||||
var displayAddress: String { url.map(Address.display) ?? address }
|
||||
}
|
||||
|
||||
enum Address {
|
||||
/// ProdDash's port unless someone changed it (config/proddash.json).
|
||||
static let defaultPort = 24500
|
||||
|
||||
/// What someone typed, as the URL ProdDash answers at: `10.3.11.152` and
|
||||
/// `10.3.11.152:24500` both become `http://10.3.11.152:24500/`. A typed
|
||||
/// scheme is taken at its word — `http://host` means port 80. A path is
|
||||
/// kept, so an address can open a particular page.
|
||||
static func normalize(_ text: String) -> URL? {
|
||||
var typed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !typed.isEmpty else { return nil }
|
||||
let hasScheme = typed.contains("://")
|
||||
if !hasScheme { typed = "http://" + typed }
|
||||
guard var parts = URLComponents(string: typed),
|
||||
let scheme = parts.scheme?.lowercased(), scheme == "http" || scheme == "https",
|
||||
let host = parts.host, !host.isEmpty else { return nil }
|
||||
parts.scheme = scheme
|
||||
if !hasScheme && parts.port == nil { parts.port = defaultPort }
|
||||
if parts.path.isEmpty { parts.path = "/" }
|
||||
return parts.url
|
||||
}
|
||||
|
||||
/// Scheme, host and port — what decides whether a link stays in the viewer.
|
||||
static func origin(_ url: URL) -> String? {
|
||||
guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil }
|
||||
let port = url.port ?? (scheme == "https" ? 443 : 80)
|
||||
return "\(scheme)://\(host):\(port)"
|
||||
}
|
||||
|
||||
/// For people to read: no trailing slash on a bare address.
|
||||
static func display(_ url: URL) -> String {
|
||||
var text = url.absoluteString
|
||||
if url.path == "/" && url.query == nil && url.fragment == nil && text.hasSuffix("/") { text.removeLast() }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Just the scheme, host and port, for people: `https://host`.
|
||||
static func originDisplay(_ url: URL) -> String {
|
||||
var parts = URLComponents()
|
||||
parts.scheme = url.scheme
|
||||
parts.host = url.host
|
||||
parts.port = url.port
|
||||
return parts.string ?? url.absoluteString
|
||||
}
|
||||
|
||||
/// `host:port`, the way the error messages name a place.
|
||||
static func place(_ url: URL?) -> String {
|
||||
guard let url, let host = url.host else { return "that address" }
|
||||
return url.port.map { "\(host):\($0)" } ?? host
|
||||
}
|
||||
}
|
||||
|
||||
/// The connections, in tab order, kept in the viewer's preferences.
|
||||
final class ConnectionStore: ObservableObject {
|
||||
|
||||
static let shared = ConnectionStore()
|
||||
private static let key = "connections"
|
||||
|
||||
@Published private(set) var connections: [Connection] {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
private init() {
|
||||
let saved = UserDefaults.standard.data(forKey: Self.key)
|
||||
connections = saved.flatMap { try? JSONDecoder().decode([Connection].self, from: $0) } ?? []
|
||||
}
|
||||
|
||||
func add(_ connection: Connection) { connections.append(connection) }
|
||||
|
||||
func update(_ connection: Connection) {
|
||||
guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return }
|
||||
connections[index] = connection
|
||||
}
|
||||
|
||||
func remove(_ id: UUID) { connections.removeAll { $0.id == id } }
|
||||
|
||||
func move(from source: IndexSet, to destination: Int) {
|
||||
connections.move(fromOffsets: source, toOffset: destination)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(connections) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Network failures in words an operator can act on.
|
||||
enum NetworkProblem {
|
||||
static func describe(_ error: Error, url: URL?) -> String {
|
||||
let ns = error as NSError
|
||||
let place = Address.place(url)
|
||||
guard ns.domain == NSURLErrorDomain else { return ns.localizedDescription }
|
||||
switch ns.code {
|
||||
case NSURLErrorCannotConnectToHost:
|
||||
return "Nothing is answering at \(place). ProdDash may not be running there, or it may use another port."
|
||||
case NSURLErrorTimedOut:
|
||||
return "\(place) didn't answer in time."
|
||||
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
|
||||
return "\(url?.host ?? place) can't be found on the network."
|
||||
case NSURLErrorNotConnectedToInternet:
|
||||
return "This Mac isn't on a network — or macOS is keeping ProdDash Viewer off it. Check System Settings → Privacy & Security → Local Network."
|
||||
case NSURLErrorNetworkConnectionLost:
|
||||
return "The connection to \(place) dropped."
|
||||
case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted,
|
||||
NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateHasBadDate,
|
||||
NSURLErrorServerCertificateNotYetValid:
|
||||
return "\(place) offered a secure connection this Mac doesn't trust."
|
||||
default:
|
||||
return ns.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks an address whether ProdDash is there, for the connection sheet.
|
||||
enum Probe {
|
||||
|
||||
enum Answer: Equatable {
|
||||
case prodDash
|
||||
case somethingElse
|
||||
case noAnswer(String)
|
||||
}
|
||||
|
||||
private static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 4
|
||||
config.timeoutIntervalForResource = 6
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
/// `/api/modules` is the first thing every dashboard asks for, and it
|
||||
/// answers without a PIN — so `{"modules": …}` there means ProdDash.
|
||||
static func ask(_ url: URL) async -> Answer {
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return .somethingElse }
|
||||
parts.path = "/api/modules"
|
||||
parts.query = nil
|
||||
parts.fragment = nil
|
||||
guard let target = parts.url else { return .somethingElse }
|
||||
do {
|
||||
let (data, response) = try await session.data(from: target)
|
||||
let ok = (response as? HTTPURLResponse)?.statusCode == 200
|
||||
let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return ok && body?["modules"] != nil ? .prodDash : .somethingElse
|
||||
} catch {
|
||||
return .noAnswer(NetworkProblem.describe(error, url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
/**
|
||||
One installation's dashboard: its web view, and what shows instead while the
|
||||
installation can't be reached. Every page stays alive while the viewer runs,
|
||||
so switching tabs is instant and the dashboards behind the one on screen stay
|
||||
live.
|
||||
*/
|
||||
final class DashboardPage: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||
|
||||
final class Status: ObservableObject {
|
||||
enum Phase: Equatable {
|
||||
case connecting
|
||||
case showing
|
||||
case unreachable(String)
|
||||
}
|
||||
@Published var phase: Phase = .connecting
|
||||
@Published var name = ""
|
||||
@Published var address = ""
|
||||
}
|
||||
|
||||
private(set) var connection: Connection
|
||||
/// What the window shows: the web view, with the status view over it.
|
||||
let view = NSView()
|
||||
private(set) var webView: WKWebView
|
||||
let status = Status()
|
||||
/// Something the tab bar or the menus show has changed.
|
||||
var onChange: () -> Void = {}
|
||||
|
||||
private let window: () -> NSWindow?
|
||||
private var statusView: NSView!
|
||||
private var retryTimer: Timer?
|
||||
private var retryDelay: TimeInterval = 2
|
||||
private var revealTimer: Timer?
|
||||
private var lastAttempt: URL?
|
||||
|
||||
var isUnreachable: Bool {
|
||||
if case .unreachable = status.phase { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
init(connection: Connection, window: @escaping () -> NSWindow?) {
|
||||
self.connection = connection
|
||||
self.window = window
|
||||
self.webView = Self.makeWebView()
|
||||
super.init()
|
||||
status.name = connection.displayName
|
||||
status.address = connection.displayAddress
|
||||
install(webView)
|
||||
let overlay = NSHostingView(rootView: PageStatusView(status: status) { [weak self] in self?.retry() })
|
||||
overlay.sizingOptions = [] // sized by the window, never the other way round
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(overlay)
|
||||
pin(overlay)
|
||||
statusView = overlay
|
||||
}
|
||||
|
||||
private static func makeWebView() -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .default()
|
||||
// The dashboard's own full-screen button uses the Fullscreen API.
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
// An alert may need to sound on a dashboard nobody has touched.
|
||||
config.mediaTypesRequiringUserActionForPlayback = []
|
||||
config.applicationNameForUserAgent = "ProdDashViewer/\(AppDelegate.version)"
|
||||
let web = WKWebView(frame: .zero, configuration: config)
|
||||
web.allowsBackForwardNavigationGestures = true
|
||||
// Safari's Develop menu can inspect a dashboard running in here.
|
||||
if #available(macOS 13.3, *) { web.isInspectable = true }
|
||||
return web
|
||||
}
|
||||
|
||||
private func install(_ web: WKWebView) {
|
||||
web.navigationDelegate = self
|
||||
web.uiDelegate = self
|
||||
// Hidden until it has drawn something: no white flash on a dark booth
|
||||
// display while the first load is in flight.
|
||||
web.isHidden = true
|
||||
web.translatesAutoresizingMaskIntoConstraints = false
|
||||
if let statusView {
|
||||
view.addSubview(web, positioned: .below, relativeTo: statusView)
|
||||
} else {
|
||||
view.addSubview(web)
|
||||
}
|
||||
pin(web)
|
||||
}
|
||||
|
||||
private func pin(_ subview: NSView) {
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
subview.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func load() { load(connection.url) }
|
||||
|
||||
private func load(_ url: URL?) {
|
||||
guard let url else {
|
||||
return show(.unreachable("“\(connection.address)” isn't an address ProdDash Viewer can open. Change it in Settings."))
|
||||
}
|
||||
lastAttempt = url
|
||||
webView.load(URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 15))
|
||||
}
|
||||
|
||||
/// Reload, or — while the installation can't be reached — try again now.
|
||||
func reload() {
|
||||
if isUnreachable || webView.url == nil { return retry() }
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
func goBack() { webView.goBack() }
|
||||
func goForward() { webView.goForward() }
|
||||
|
||||
func retry() {
|
||||
retryTimer?.invalidate()
|
||||
load(lastAttempt ?? connection.url)
|
||||
}
|
||||
|
||||
/// An installation that isn't answering is usually one restarting — an
|
||||
/// update, a reboot — so keep asking, less often the longer it takes.
|
||||
private func scheduleRetry() {
|
||||
retryTimer?.invalidate()
|
||||
retryTimer = Timer.scheduledTimer(withTimeInterval: retryDelay, repeats: false) { [weak self] _ in
|
||||
self?.retry()
|
||||
}
|
||||
retryDelay = min(retryDelay * 2, 15)
|
||||
}
|
||||
|
||||
/// Settings changed this connection. A new address is a different
|
||||
/// installation, so it gets a fresh web view — Back can't wander into the
|
||||
/// old one.
|
||||
func update(_ new: Connection) {
|
||||
let moved = new.address != connection.address
|
||||
connection = new
|
||||
status.name = new.displayName
|
||||
status.address = new.displayAddress
|
||||
guard moved else { return onChange() }
|
||||
retire(webView)
|
||||
webView = Self.makeWebView()
|
||||
install(webView)
|
||||
retryTimer?.invalidate()
|
||||
revealTimer?.invalidate()
|
||||
retryDelay = 2
|
||||
show(.connecting)
|
||||
load()
|
||||
}
|
||||
|
||||
/// The connection was removed.
|
||||
func close() {
|
||||
retryTimer?.invalidate()
|
||||
revealTimer?.invalidate()
|
||||
retire(webView)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func retire(_ web: WKWebView) {
|
||||
web.stopLoading()
|
||||
web.navigationDelegate = nil
|
||||
web.uiDelegate = nil
|
||||
web.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func show(_ phase: Status.Phase) {
|
||||
status.phase = phase
|
||||
statusView.isHidden = phase == .showing
|
||||
onChange()
|
||||
}
|
||||
|
||||
private func isHome(_ url: URL) -> Bool {
|
||||
guard let home = connection.url.flatMap(Address.origin) else { return false }
|
||||
return Address.origin(url) == home
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard webView === self.webView else { return }
|
||||
reveal()
|
||||
}
|
||||
|
||||
/// Normally the load finishes within moments of this; but a tile's frame
|
||||
/// that never answers holds the load event back, and the dashboard
|
||||
/// shouldn't sit behind "Connecting…" waiting for it.
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
guard webView === self.webView, status.phase != .showing else { return }
|
||||
revealTimer?.invalidate()
|
||||
revealTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self, weak webView] _ in
|
||||
guard let self, let webView, webView === self.webView else { return }
|
||||
self.reveal()
|
||||
}
|
||||
}
|
||||
|
||||
private func reveal() {
|
||||
revealTimer?.invalidate()
|
||||
retryTimer?.invalidate()
|
||||
retryDelay = 2
|
||||
webView.isHidden = false
|
||||
if status.phase != .showing { show(.showing) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
failed(webView, error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
failed(webView, error)
|
||||
}
|
||||
|
||||
private func failed(_ web: WKWebView, _ error: Error) {
|
||||
guard web === webView else { return }
|
||||
let ns = error as NSError
|
||||
// Not failures: a load replaced by another, or one handed to the browser.
|
||||
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { return }
|
||||
if ns.domain == "WebKitErrorDomain" && (ns.code == 102 || ns.code == 204) { return }
|
||||
if let failing = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL, isHome(failing) {
|
||||
lastAttempt = failing
|
||||
}
|
||||
show(.unreachable(NetworkProblem.describe(error, url: lastAttempt)))
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
/// The page's process died (memory pressure, a WebKit crash). A booth
|
||||
/// display is never left blank: bring it back — after the usual retry
|
||||
/// delay, so a page that takes its process down as it loads can't spin.
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
guard webView === self.webView else { return }
|
||||
if let url = webView.url, isHome(url) { lastAttempt = url }
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
/// The viewer shows ProdDash and nothing else: the installation's own
|
||||
/// pages open here, any other site opens in the browser.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = action.request.url else { return decisionHandler(.allow) }
|
||||
// A tile may embed another site in a frame; only the page itself is policed.
|
||||
guard action.targetFrame?.isMainFrame ?? true else { return decisionHandler(.allow) }
|
||||
switch url.scheme?.lowercased() {
|
||||
case "http", "https":
|
||||
if !isHome(url) {
|
||||
let followed = action.targetFrame == nil || status.phase == .showing
|
||||
|| action.navigationType == .linkActivated || action.navigationType == .formSubmitted
|
||||
if followed {
|
||||
NSWorkspace.shared.open(url) // someone followed a link off ProdDash
|
||||
} else {
|
||||
redirected(to: url)
|
||||
}
|
||||
return decisionHandler(.cancel)
|
||||
}
|
||||
if action.targetFrame == nil {
|
||||
// target="_blank" or window.open on ProdDash's own page: one
|
||||
// window, so it opens here.
|
||||
let request = action.request
|
||||
DispatchQueue.main.async { webView.load(request) }
|
||||
return decisionHandler(.cancel)
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
case "about", "blob", "data":
|
||||
decisionHandler(.allow)
|
||||
case "file":
|
||||
// A file dropped somewhere the page didn't take it.
|
||||
decisionHandler(.cancel)
|
||||
default:
|
||||
NSWorkspace.shared.open(url) // mailto: and the like
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something the page can't show is a download: the browser does those.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
if response.isForMainFrame, !response.canShowMIMEType, let url = response.response.url {
|
||||
NSWorkspace.shared.open(url)
|
||||
return decisionHandler(.cancel)
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
/// The address itself sends the viewer somewhere else — a proxy, http
|
||||
/// moving to https. Handing that to the browser would leave the page
|
||||
/// stuck connecting, and do it again on every retry.
|
||||
private func redirected(to url: URL) {
|
||||
show(.unreachable("\(Address.place(connection.url)) sends ProdDash Viewer on to \(Address.originDisplay(url)). If that's where ProdDash answers, change the address in Settings to it."))
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
/// window.open(), which doesn't pass through decidePolicyFor: ProdDash's
|
||||
/// own pages open here, other sites in the browser. The viewer never
|
||||
/// opens a second window.
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
guard let url = action.request.url else { return nil }
|
||||
if isHome(url) {
|
||||
webView.load(action.request)
|
||||
} else if url.scheme == "http" || url.scheme == "https" {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - JavaScript dialogs and file pickers
|
||||
|
||||
// A WKWebView answers alert, confirm and prompt with "no" unless the app
|
||||
// shows them — and the admin page confirms before deleting a layout, Band
|
||||
// Lineup prompts for names.
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
let alert = dialog(message)
|
||||
alert.addButton(withTitle: "OK")
|
||||
present(alert) { _ in completionHandler() }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
let alert = dialog(message)
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
present(alert) { completionHandler($0 == .alertFirstButtonReturn) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String,
|
||||
defaultText: String?, initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping (String?) -> Void) {
|
||||
let alert = dialog(prompt)
|
||||
let field = NSTextField(string: defaultText ?? "")
|
||||
field.frame = NSRect(x: 0, y: 0, width: 280, height: 22)
|
||||
alert.accessoryView = field
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.window.initialFirstResponder = field
|
||||
present(alert) { completionHandler($0 == .alertFirstButtonReturn ? field.stringValue : nil) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = parameters.allowsDirectories
|
||||
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||
if let window = window(), window.attachedSheet == nil {
|
||||
panel.beginSheetModal(for: window) { completionHandler($0 == .OK ? panel.urls : nil) }
|
||||
} else {
|
||||
completionHandler(panel.runModal() == .OK ? panel.urls : nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Titled with the installation's name, so it's clear which one is asking.
|
||||
private func dialog(_ message: String) -> NSAlert {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = connection.displayName
|
||||
alert.informativeText = message
|
||||
return alert
|
||||
}
|
||||
|
||||
private func present(_ alert: NSAlert, then done: @escaping (NSApplication.ModalResponse) -> Void) {
|
||||
if let window = window(), window.attachedSheet == nil {
|
||||
alert.beginSheetModal(for: window, completionHandler: done)
|
||||
} else {
|
||||
done(alert.runModal())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Over the web view while there is nothing to show yet, or while the
|
||||
/// installation can't be reached.
|
||||
struct PageStatusView: View {
|
||||
|
||||
@ObservedObject var status: DashboardPage.Status
|
||||
let retry: () -> Void
|
||||
/// A fast load shouldn't flash a spinner.
|
||||
@State private var patient = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
switch status.phase {
|
||||
case .showing:
|
||||
EmptyView()
|
||||
case .connecting:
|
||||
if patient {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Connecting to \(status.name)…").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
case .unreachable(let why):
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "network.slash")
|
||||
.font(.system(size: 44, weight: .light))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
Text("Can’t Reach \(status.name)")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text(why)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 420)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(status.address)
|
||||
.font(.callout.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
.textSelection(.enabled)
|
||||
Button("Try Again", action: retry)
|
||||
.controlSize(.large)
|
||||
.padding(.top, 8)
|
||||
Text("ProdDash Viewer keeps trying by itself.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
patient = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
/// Help → ProdDash Viewer Help: the page that ships inside the app, so it
|
||||
/// opens with no network and always matches this version.
|
||||
final class HelpWindowController: NSWindowController, WKNavigationDelegate {
|
||||
|
||||
convenience init() {
|
||||
let web = WKWebView()
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 720),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = "ProdDash Viewer Help"
|
||||
window.minSize = NSSize(width: 420, height: 360)
|
||||
window.contentView = web
|
||||
window.tabbingMode = .disallowed
|
||||
window.isRestorable = false
|
||||
window.center()
|
||||
window.setFrameAutosaveName("ProdDashViewerHelp")
|
||||
self.init(window: window)
|
||||
web.navigationDelegate = self
|
||||
if let page = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "Help") {
|
||||
web.loadFileURL(page, allowingReadAccessTo: page.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
showWindow(nil)
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
/// The help page itself stays here; its links to the web open in the browser.
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = action.request.url, !url.isFileURL, url.scheme != "about" else {
|
||||
return decisionHandler(.allow)
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Settings (⌘,): a standard toolbar-tabbed settings window with two panes,
|
||||
/// Connections and General. Every change applies as it's made.
|
||||
final class SettingsWindowController: NSWindowController {
|
||||
|
||||
private var model: SettingsModel!
|
||||
private var tabs: NSTabViewController!
|
||||
|
||||
convenience init() {
|
||||
let model = SettingsModel()
|
||||
let tabs = NSTabViewController()
|
||||
tabs.tabStyle = .toolbar
|
||||
tabs.addTabViewItem(Self.pane("Connections", symbol: "network",
|
||||
ConnectionsPane(store: .shared, model: model)))
|
||||
tabs.addTabViewItem(Self.pane("General", symbol: "gearshape",
|
||||
GeneralPane(settings: .shared)))
|
||||
let window = NSWindow(contentViewController: tabs)
|
||||
window.styleMask = [.titled, .closable]
|
||||
window.toolbarStyle = .preference
|
||||
window.isRestorable = false
|
||||
self.init(window: window)
|
||||
self.model = model
|
||||
self.tabs = tabs
|
||||
window.center()
|
||||
}
|
||||
|
||||
private static func pane<Content: View>(_ label: String, symbol: String, _ content: Content) -> NSTabViewItem {
|
||||
let controller = NSHostingController(rootView: content)
|
||||
controller.sizingOptions = .preferredContentSize
|
||||
controller.title = label // the window takes its title from the pane
|
||||
let item = NSTabViewItem(viewController: controller)
|
||||
item.label = label
|
||||
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
|
||||
return item
|
||||
}
|
||||
|
||||
/// Show Settings; `addingConnection` opens it on the add-a-connection sheet.
|
||||
func show(addingConnection: Bool = false) {
|
||||
if addingConnection {
|
||||
tabs.selectedTabViewItemIndex = 0
|
||||
model.pendingAdd = true
|
||||
}
|
||||
showWindow(nil)
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
final class SettingsModel: ObservableObject {
|
||||
/// Set to open the add sheet as soon as the Connections pane can.
|
||||
@Published var pendingAdd = false
|
||||
}
|
||||
|
||||
// MARK: - Connections
|
||||
|
||||
private struct ConnectionDraft: Identifiable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var address: String
|
||||
var isNew: Bool
|
||||
}
|
||||
|
||||
private struct ConnectionsPane: View {
|
||||
|
||||
@ObservedObject var store: ConnectionStore
|
||||
@ObservedObject var model: SettingsModel
|
||||
@State private var selection: UUID?
|
||||
@State private var editing: ConnectionDraft?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Each connection is a ProdDash installation on your network. With more than one, a tab bar at the top of the window switches between them — drag them into the order you want.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
List(selection: $selection) {
|
||||
ForEach(store.connections) { connection in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(connection.displayName)
|
||||
Text(connection.displayAddress)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
.tag(connection.id)
|
||||
}
|
||||
.onMove { store.move(from: $0, to: $1) }
|
||||
}
|
||||
.listStyle(.bordered(alternatesRowBackgrounds: true))
|
||||
.contextMenu(forSelectionType: UUID.self) { ids in
|
||||
Button("Edit…") { edit(ids.first) }
|
||||
.disabled(ids.count != 1)
|
||||
Button("Remove") {
|
||||
ids.forEach(store.remove)
|
||||
if let selected = selection, ids.contains(selected) { selection = nil }
|
||||
}
|
||||
} primaryAction: { ids in
|
||||
edit(ids.first)
|
||||
}
|
||||
.overlay {
|
||||
if store.connections.isEmpty {
|
||||
Text("No Connections").foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(height: 210)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Button(action: add) {
|
||||
Image(systemName: "plus").frame(width: 14)
|
||||
}
|
||||
.help("Add a connection")
|
||||
.accessibilityLabel("Add")
|
||||
Button(action: removeSelected) {
|
||||
Image(systemName: "minus").frame(width: 14)
|
||||
}
|
||||
.help("Remove the selected connection")
|
||||
.accessibilityLabel("Remove")
|
||||
.disabled(selection == nil)
|
||||
Spacer()
|
||||
Button("Edit…") { edit(selection) }
|
||||
.disabled(selection == nil)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
.sheet(item: $editing) { draft in
|
||||
ConnectionEditor(draft: draft, onSave: save)
|
||||
}
|
||||
.onReceive(model.$pendingAdd) { pending in
|
||||
guard pending else { return }
|
||||
DispatchQueue.main.async {
|
||||
model.pendingAdd = false
|
||||
add()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func add() {
|
||||
editing = ConnectionDraft(id: UUID(), name: "", address: "", isNew: true)
|
||||
}
|
||||
|
||||
private func edit(_ id: UUID?) {
|
||||
guard let id, let connection = store.connections.first(where: { $0.id == id }) else { return }
|
||||
editing = ConnectionDraft(id: id, name: connection.name, address: connection.displayAddress, isNew: false)
|
||||
}
|
||||
|
||||
private func removeSelected() {
|
||||
guard let id = selection else { return }
|
||||
store.remove(id)
|
||||
selection = nil
|
||||
}
|
||||
|
||||
private func save(_ draft: ConnectionDraft) {
|
||||
guard let url = Address.normalize(draft.address) else { return }
|
||||
let connection = Connection(id: draft.id,
|
||||
name: draft.name.trimmingCharacters(in: .whitespaces),
|
||||
address: url.absoluteString)
|
||||
if draft.isNew { store.add(connection) } else { store.update(connection) }
|
||||
selection = connection.id
|
||||
}
|
||||
}
|
||||
|
||||
/// The add / edit sheet. It asks the address whether ProdDash is there as
|
||||
/// you type, so a mistyped address shows up here rather than on the booth
|
||||
/// display — but saving never depends on it: the installation may simply be
|
||||
/// switched off right now.
|
||||
private struct ConnectionEditor: View {
|
||||
|
||||
@State var draft: ConnectionDraft
|
||||
let onSave: (ConnectionDraft) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private enum Check: Equatable { case idle, checking, answer(Probe.Answer) }
|
||||
@State private var check: Check = .idle
|
||||
|
||||
private var url: URL? { Address.normalize(draft.address) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text(draft.isNew ? "Add Connection" : "Edit Connection")
|
||||
.font(.headline)
|
||||
|
||||
Form {
|
||||
TextField("Name:", text: $draft.name, prompt: Text(url?.host ?? "AB Booth"))
|
||||
TextField("Address:", text: $draft.address, prompt: Text("10.3.11.152:24500"))
|
||||
LabeledContent {
|
||||
status
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(draft.isNew ? "Add" : "Save") {
|
||||
onSave(draft)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(url == nil)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 460)
|
||||
.task(id: draft.address) {
|
||||
guard let url else { return check = .idle }
|
||||
check = .checking
|
||||
try? await Task.sleep(for: .milliseconds(500)) // let the typing finish
|
||||
guard !Task.isCancelled else { return }
|
||||
let answer = await Probe.ask(url)
|
||||
guard !Task.isCancelled else { return }
|
||||
check = .answer(answer)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var status: some View {
|
||||
switch check {
|
||||
case .idle where url == nil:
|
||||
Text(draft.address.isEmpty
|
||||
? "The address you open ProdDash at in a browser. Without a port, ProdDash’s \(String(Address.defaultPort)) is used."
|
||||
: "That isn’t an address.")
|
||||
.foregroundStyle(.secondary)
|
||||
case .idle, .checking: // idle with an address: the check is about to start
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini)
|
||||
Text("Checking \(url.map(Address.display) ?? "")…").foregroundStyle(.secondary)
|
||||
}
|
||||
case .answer(.prodDash):
|
||||
Label("ProdDash is answering at \(url.map(Address.display) ?? "").", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .answer(.somethingElse):
|
||||
Label("Something answers at \(url.map(Address.display) ?? "") — but it isn’t ProdDash.", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
case .answer(.noAnswer(let why)):
|
||||
Label(why, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
private struct GeneralPane: View {
|
||||
|
||||
@ObservedObject var settings: ViewerSettings
|
||||
@State private var openAtLogin = LoginItem.isEnabled || LoginItem.needsApproval
|
||||
@State private var needsApproval = LoginItem.needsApproval
|
||||
@State private var loginProblem: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Open at login", isOn: Binding(
|
||||
get: { openAtLogin },
|
||||
set: { wanted in
|
||||
loginProblem = LoginItem.set(wanted)
|
||||
needsApproval = LoginItem.needsApproval
|
||||
openAtLogin = LoginItem.isEnabled || needsApproval
|
||||
}))
|
||||
if needsApproval {
|
||||
HStack {
|
||||
Text("Waiting for your approval in System Settings.")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Open Login Items…") { LoginItem.openSystemSettings() }
|
||||
}
|
||||
}
|
||||
if let loginProblem {
|
||||
Text(loginProblem).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Toggle(isOn: $settings.keepDisplayAwake) {
|
||||
Text("Keep the display awake")
|
||||
Text("While a dashboard is on screen, the display doesn’t dim or sleep.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.frame(width: 520, height: needsApproval || loginProblem != nil ? 200 : 152)
|
||||
.onAppear(perform: refreshLoginItem)
|
||||
// Approving it in System Settings happens elsewhere; catch up on return.
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in
|
||||
refreshLoginItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshLoginItem() {
|
||||
needsApproval = LoginItem.needsApproval
|
||||
openAtLogin = LoginItem.isEnabled || needsApproval
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// What the tab bar shows: one tab per connection, in Settings' order.
|
||||
final class TabBarModel: ObservableObject {
|
||||
|
||||
struct Tab: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var title: String
|
||||
var address: String
|
||||
var unreachable: Bool
|
||||
}
|
||||
|
||||
@Published var tabs: [Tab] = []
|
||||
@Published var selection: UUID?
|
||||
var onSelect: (UUID) -> Void = { _ in }
|
||||
}
|
||||
|
||||
/// Hosts the tabs. A click on a tab works even while another app is in
|
||||
/// front, rather than only bringing the viewer forward.
|
||||
final class TabBarHostingView: NSHostingView<TabBar> {
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
}
|
||||
|
||||
/// The slim bar of installation names. It sits in the window's title bar
|
||||
/// area, so it takes the title bar's material in light and dark alike.
|
||||
struct TabBar: View {
|
||||
|
||||
@ObservedObject var model: TabBarModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(model.tabs) { tab in
|
||||
TabButton(tab: tab, selected: tab.id == model.selection) { model.onSelect(tab.id) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TabButton: View {
|
||||
|
||||
let tab: TabBarModel.Tab
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 5) {
|
||||
if tab.unreachable {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text(tab.title)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? .primary : .secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, minHeight: 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.primary.opacity(selected ? 0.12 : hovering ? 0.06 : 0))
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false) // the keyboard belongs to the dashboard; ⌃Tab switches
|
||||
.onHover { hovering = $0 }
|
||||
.help(tab.unreachable ? "\(tab.address) — not answering" : tab.address)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
/// The viewer's own preferences. The connections themselves are in
|
||||
/// ConnectionStore.
|
||||
final class ViewerSettings: ObservableObject {
|
||||
|
||||
static let shared = ViewerSettings()
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
/// Keep the display on while a dashboard is on screen — a booth display
|
||||
/// that dims halfway through a service is the failure this is here for.
|
||||
@Published var keepDisplayAwake: Bool {
|
||||
didSet { defaults.set(keepDisplayAwake, forKey: "keepDisplayAwake") }
|
||||
}
|
||||
|
||||
private init() {
|
||||
defaults.register(defaults: ["keepDisplayAwake": true])
|
||||
keepDisplayAwake = defaults.bool(forKey: "keepDisplayAwake")
|
||||
}
|
||||
|
||||
/// The window was in full screen when the viewer last quit, so it opens in
|
||||
/// full screen again — a booth Mac that restarts comes back as it was left.
|
||||
var reopenInFullScreen: Bool {
|
||||
get { defaults.bool(forKey: "reopenInFullScreen") }
|
||||
set { defaults.set(newValue, forKey: "reopenInFullScreen") }
|
||||
}
|
||||
|
||||
/// The tab that was showing, so the viewer reopens on it.
|
||||
var selectedConnection: UUID? {
|
||||
get { defaults.string(forKey: "selectedConnection").flatMap(UUID.init(uuidString:)) }
|
||||
set { defaults.set(newValue?.uuidString, forKey: "selectedConnection") }
|
||||
}
|
||||
}
|
||||
|
||||
/// "Open at login", through the macOS 13 login-item API. The operator may
|
||||
/// still have to approve it in System Settings — hence `needsApproval`, which
|
||||
/// Settings shows rather than failing silently.
|
||||
enum LoginItem {
|
||||
|
||||
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
|
||||
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
|
||||
|
||||
/// Nil when it worked; otherwise what went wrong, for Settings to show.
|
||||
static func set(_ enabled: Bool) -> String? {
|
||||
do {
|
||||
if enabled {
|
||||
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
|
||||
} else {
|
||||
if SMAppService.mainApp.status != .notRegistered { try SMAppService.mainApp.unregister() }
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
static func openSystemSettings() {
|
||||
SMAppService.openSystemSettingsLoginItems()
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/**
|
||||
The viewer's one window: the selected installation's dashboard and — when
|
||||
there is more than one — a slim tab bar of their names.
|
||||
|
||||
In a window the bar floats over the top of the dashboard, just under the
|
||||
title bar: it comes out while the pointer is on the title bar and tucks away
|
||||
after. It is deliberately not part of the title bar — anything there makes
|
||||
WebKit push the whole page down to clear it, and a dashboard jumping every
|
||||
time the bar came and went would be worse than no bar at all.
|
||||
|
||||
In full screen the bar *is* part of the title bar (a titlebar accessory), so
|
||||
it comes down with the menu bar like any Mac app's toolbar; there the title
|
||||
bar lives in a window of its own and the page stays put.
|
||||
*/
|
||||
final class ViewerWindowController: NSWindowController, NSWindowDelegate, NSMenuItemValidation {
|
||||
|
||||
static let tabBarHeight: CGFloat = 32
|
||||
|
||||
/// The standard title bar's height: where the dashboard starts.
|
||||
private static let titlebarHeight: CGFloat = {
|
||||
let probe = NSRect(x: 0, y: 0, width: 100, height: 100)
|
||||
return NSWindow.frameRect(forContentRect: probe, styleMask: [.titled]).height - probe.height
|
||||
}()
|
||||
|
||||
/// Empty state's "Add Connection…".
|
||||
var onAddConnection: () -> Void = {}
|
||||
|
||||
private let store = ConnectionStore.shared
|
||||
private let settings = ViewerSettings.shared
|
||||
private var pages: [UUID: DashboardPage] = [:]
|
||||
private var order: [UUID] = []
|
||||
private var selection: UUID?
|
||||
|
||||
private let content = ViewerContentView()
|
||||
private let pageArea = NSView()
|
||||
private var pageAreaTop: NSLayoutConstraint!
|
||||
private var emptyState: NSView!
|
||||
|
||||
private let tabModel = TabBarModel()
|
||||
private var tabOverlay: TabBarOverlay!
|
||||
private var tabOverlayTop: NSLayoutConstraint!
|
||||
private let tabAccessory = NSTitlebarAccessoryViewController()
|
||||
private var accessoryAttached = false
|
||||
private var tabBarShown = false
|
||||
private var hideTimer: Timer?
|
||||
private var inFullScreen = false
|
||||
private var leaving = false
|
||||
|
||||
private var awakeActivity: NSObjectProtocol?
|
||||
private var keyMonitor: Any?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var currentPage: DashboardPage? { selection.flatMap { pages[$0] } }
|
||||
private var hasTabs: Bool { order.count > 1 }
|
||||
|
||||
convenience init() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = "ProdDash Viewer"
|
||||
window.minSize = NSSize(width: 640, height: 420)
|
||||
window.tabbingMode = .disallowed // the tab bar here is ProdDash's, not AppKit's
|
||||
window.collectionBehavior.insert(.fullScreenPrimary)
|
||||
window.isRestorable = false // frame and full screen are kept below instead
|
||||
window.center()
|
||||
window.setFrameAutosaveName("ProdDashViewerWindow")
|
||||
self.init(window: window)
|
||||
window.delegate = self
|
||||
build(in: window)
|
||||
}
|
||||
|
||||
private func build(in window: NSWindow) {
|
||||
// The content runs under the title bar so the pointer can be watched
|
||||
// there; the dashboard itself starts below it.
|
||||
window.contentView = content
|
||||
|
||||
pageArea.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(pageArea)
|
||||
pageAreaTop = pageArea.topAnchor.constraint(equalTo: content.topAnchor, constant: Self.titlebarHeight)
|
||||
|
||||
let empty = NSHostingView(rootView: EmptyStateView { [weak self] in self?.onAddConnection() })
|
||||
empty.sizingOptions = [] // sized by the window, never the other way round
|
||||
empty.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(empty)
|
||||
emptyState = empty
|
||||
|
||||
tabOverlay = TabBarOverlay(model: tabModel)
|
||||
tabOverlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
tabOverlay.isHidden = true
|
||||
tabOverlay.alphaValue = 0
|
||||
content.addSubview(tabOverlay)
|
||||
tabOverlayTop = tabOverlay.topAnchor.constraint(equalTo: content.topAnchor, constant: hiddenOverlayTop)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
pageAreaTop,
|
||||
pageArea.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
pageArea.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
pageArea.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
||||
empty.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||||
empty.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||||
empty.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||||
empty.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||||
tabOverlayTop,
|
||||
tabOverlay.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
tabOverlay.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
tabOverlay.heightAnchor.constraint(equalToConstant: Self.tabBarHeight),
|
||||
])
|
||||
|
||||
let bar = TabBarHostingView(rootView: TabBar(model: tabModel))
|
||||
bar.sizingOptions = []
|
||||
bar.frame = NSRect(x: 0, y: 0, width: 800, height: Self.tabBarHeight)
|
||||
tabAccessory.view = bar
|
||||
tabAccessory.layoutAttribute = .bottom
|
||||
tabAccessory.fullScreenMinHeight = 0 // out of sight until the menu bar comes down
|
||||
tabModel.onSelect = { [weak self] id in self?.select(id) }
|
||||
|
||||
content.topZoneHeight = { [weak self] in self?.revealZoneHeight ?? 0 }
|
||||
content.onPointerInTopZone = { [weak self] inside in self?.pointerInTopZone(inside) }
|
||||
|
||||
selection = settings.selectedConnection
|
||||
// @Published sends before it stores, so use the value it sends.
|
||||
store.$connections
|
||||
.sink { [weak self] connections in self?.sync(connections) }
|
||||
.store(in: &cancellables)
|
||||
settings.$keepDisplayAwake
|
||||
.sink { [weak self] keep in self?.updateDisplayAwake(keep) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
installKeyboardSwitching()
|
||||
}
|
||||
|
||||
// MARK: - Connections and tabs
|
||||
|
||||
private func sync(_ connections: [Connection]) {
|
||||
let ids = Set(connections.map(\.id))
|
||||
let hadTabs = hasTabs
|
||||
for (id, page) in pages where !ids.contains(id) {
|
||||
page.close()
|
||||
pages[id] = nil
|
||||
}
|
||||
for connection in connections {
|
||||
if let page = pages[connection.id] {
|
||||
page.update(connection)
|
||||
} else {
|
||||
let page = DashboardPage(connection: connection) { [weak self] in self?.window }
|
||||
page.onChange = { [weak self, weak page] in
|
||||
if let page { self?.pageChanged(page) }
|
||||
}
|
||||
pages[connection.id] = page
|
||||
// A dashboard behind the one on screen still lays itself out
|
||||
// at the window's size, not at nothing.
|
||||
page.view.frame = NSRect(x: 0, y: 0, width: content.bounds.width,
|
||||
height: max(content.bounds.height - pageAreaTop.constant, 0))
|
||||
page.view.layoutSubtreeIfNeeded()
|
||||
page.load()
|
||||
}
|
||||
}
|
||||
order = connections.map(\.id)
|
||||
if selection.map(ids.contains) != true { selection = order.first }
|
||||
showSelection()
|
||||
refreshTabs()
|
||||
placeTabBar()
|
||||
if hasTabs && !hadTabs { flashTabBar(for: 3) } // out long enough to be noticed
|
||||
updateDisplayAwake(settings.keepDisplayAwake)
|
||||
}
|
||||
|
||||
private func pageChanged(_ page: DashboardPage) {
|
||||
refreshTabs()
|
||||
if page === currentPage {
|
||||
updateTitle()
|
||||
// A new address means a new web view: put it on screen.
|
||||
if page.view.superview !== pageArea { showSelection() }
|
||||
// The web view can't take the keyboard until it's shown something.
|
||||
if page.status.phase == .showing, (window?.firstResponder as? NSView)?.isDescendant(of: page.webView) != true {
|
||||
window?.makeFirstResponder(page.webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showSelection() {
|
||||
let page = currentPage
|
||||
for view in pageArea.subviews where view !== page?.view { view.removeFromSuperview() }
|
||||
if let page, page.view.superview !== pageArea {
|
||||
page.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
pageArea.addSubview(page.view)
|
||||
NSLayoutConstraint.activate([
|
||||
page.view.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||||
page.view.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||||
page.view.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||||
page.view.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||||
])
|
||||
}
|
||||
emptyState.isHidden = page != nil
|
||||
if let page { window?.makeFirstResponder(page.webView) }
|
||||
settings.selectedConnection = selection
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
private func updateTitle() {
|
||||
window?.title = currentPage?.connection.displayName ?? "ProdDash Viewer"
|
||||
}
|
||||
|
||||
private func refreshTabs() {
|
||||
tabModel.tabs = order.compactMap { id in
|
||||
pages[id].map {
|
||||
TabBarModel.Tab(id: id, title: $0.connection.displayName,
|
||||
address: $0.connection.displayAddress, unreachable: $0.isUnreachable)
|
||||
}
|
||||
}
|
||||
tabModel.selection = selection
|
||||
}
|
||||
|
||||
func select(_ id: UUID) {
|
||||
guard pages[id] != nil else { return }
|
||||
if id != selection {
|
||||
selection = id
|
||||
showSelection()
|
||||
refreshTabs()
|
||||
}
|
||||
flashTabBar(for: 2)
|
||||
}
|
||||
|
||||
private func select(index: Int) {
|
||||
guard order.indices.contains(index) else { return }
|
||||
select(order[index])
|
||||
}
|
||||
|
||||
private func step(_ delta: Int) {
|
||||
guard !order.isEmpty else { return }
|
||||
let index = selection.flatMap { order.firstIndex(of: $0) } ?? 0
|
||||
select(index: (index + delta + order.count) % order.count)
|
||||
}
|
||||
|
||||
/// ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 (⌘9 the last), as in Safari. Not menu items:
|
||||
/// the menus stay as short as the viewer is.
|
||||
private func installKeyboardSwitching() {
|
||||
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self, event.window === self.window, self.hasTabs else { return event }
|
||||
// Only the keys that are pressed together — Caps Lock doesn't count.
|
||||
let mods = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||
if event.keyCode == 48, mods.subtracting(.shift) == .control { // Tab
|
||||
self.step(mods.contains(.shift) ? -1 : 1)
|
||||
return nil
|
||||
}
|
||||
if mods == .command, let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...9).contains(digit) {
|
||||
self.select(index: digit == 9 ? self.order.count - 1 : digit - 1)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The tab bar coming and going
|
||||
|
||||
/// Overlay in a window, titlebar accessory in full screen — and neither
|
||||
/// with only one connection. The accessory stays attached whenever there
|
||||
/// are tabs, so it is already in place when AppKit builds the full-screen
|
||||
/// title bar; in a window it is hidden, and hidden it takes no room, so
|
||||
/// the page stays put.
|
||||
private func placeTabBar() {
|
||||
tabAccessory.isHidden = !inFullScreen
|
||||
if hasTabs != accessoryAttached {
|
||||
accessoryAttached = hasTabs
|
||||
if hasTabs {
|
||||
window?.addTitlebarAccessoryViewController(tabAccessory)
|
||||
} else {
|
||||
tabAccessory.removeFromParent()
|
||||
}
|
||||
}
|
||||
if !hasTabs {
|
||||
hideTimer?.invalidate()
|
||||
setTabBar(shown: false, animated: false)
|
||||
}
|
||||
content.updateTrackingAreas()
|
||||
}
|
||||
|
||||
/// Under the title bar in a window; the top of the screen in full screen,
|
||||
/// where it only flashes up after a switch.
|
||||
private var shownOverlayTop: CGFloat { inFullScreen ? 0 : Self.titlebarHeight }
|
||||
/// Slid up by a few points while hidden, so it slides down as it fades in.
|
||||
private var hiddenOverlayTop: CGFloat { shownOverlayTop - 8 }
|
||||
|
||||
/// The strip along the top that brings the bar out: the title bar, a
|
||||
/// sliver of the dashboard below it, and the bar itself while it's out.
|
||||
private var revealZoneHeight: CGFloat {
|
||||
guard hasTabs, !inFullScreen else { return 0 }
|
||||
return Self.titlebarHeight + (tabBarShown ? Self.tabBarHeight : 4)
|
||||
}
|
||||
|
||||
private func pointerInTopZone(_ inside: Bool) {
|
||||
if inside {
|
||||
hideTimer?.invalidate()
|
||||
setTabBar(shown: true)
|
||||
} else {
|
||||
scheduleHide(after: 0.8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the bar out for a moment — at launch, and after switching, so
|
||||
/// it's clear what changed. In full screen too, where nothing else says
|
||||
/// which installation is on screen.
|
||||
private func flashTabBar(for seconds: TimeInterval) {
|
||||
guard hasTabs else { return }
|
||||
setTabBar(shown: true)
|
||||
if !content.pointerInTopZone { scheduleHide(after: seconds) }
|
||||
}
|
||||
|
||||
private func scheduleHide(after delay: TimeInterval) {
|
||||
hideTimer?.invalidate()
|
||||
hideTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
|
||||
guard let self, !self.content.pointerInTopZone else { return }
|
||||
self.setTabBar(shown: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func setTabBar(shown: Bool, animated: Bool = true) {
|
||||
let shown = shown && hasTabs
|
||||
guard shown != tabBarShown else { return }
|
||||
tabBarShown = shown
|
||||
if shown { tabOverlay.isHidden = false }
|
||||
content.layoutSubtreeIfNeeded()
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = animated ? 0.18 : 0
|
||||
context.allowsImplicitAnimation = true
|
||||
tabOverlay.animator().alphaValue = shown ? 1 : 0
|
||||
tabOverlayTop.animator().constant = shown ? shownOverlayTop : hiddenOverlayTop
|
||||
content.layoutSubtreeIfNeeded()
|
||||
}, completionHandler: { [weak self] in
|
||||
guard let self, !self.tabBarShown else { return }
|
||||
self.tabOverlay.isHidden = true
|
||||
})
|
||||
content.updateTrackingAreas()
|
||||
}
|
||||
|
||||
// MARK: - Full screen
|
||||
|
||||
func windowWillEnterFullScreen(_ notification: Notification) {
|
||||
settleOverlay()
|
||||
inFullScreen = true
|
||||
pageAreaTop.constant = 0
|
||||
placeTabBar()
|
||||
tabOverlayTop.constant = hiddenOverlayTop
|
||||
}
|
||||
|
||||
func windowDidEnterFullScreen(_ notification: Notification) {
|
||||
if !leaving { settings.reopenInFullScreen = true }
|
||||
}
|
||||
|
||||
/// The accessory hides before the window is a window again — showing in
|
||||
/// a window's title bar, it would push the dashboard down.
|
||||
func windowWillExitFullScreen(_ notification: Notification) {
|
||||
settleOverlay()
|
||||
inFullScreen = false
|
||||
pageAreaTop.constant = Self.titlebarHeight
|
||||
placeTabBar()
|
||||
tabOverlayTop.constant = hiddenOverlayTop
|
||||
}
|
||||
|
||||
/// No overlay riding through the full-screen animation.
|
||||
private func settleOverlay() {
|
||||
hideTimer?.invalidate()
|
||||
setTabBar(shown: false, animated: false)
|
||||
}
|
||||
|
||||
func windowDidExitFullScreen(_ notification: Notification) {
|
||||
if !leaving { settings.reopenInFullScreen = false }
|
||||
content.updateTrackingAreas()
|
||||
}
|
||||
|
||||
/// Called on the way out, before AppKit takes the window out of full
|
||||
/// screen on its own account.
|
||||
func rememberFullScreen() {
|
||||
guard !leaving, let window else { return }
|
||||
leaving = true
|
||||
settings.reopenInFullScreen = window.styleMask.contains(.fullScreen)
|
||||
}
|
||||
|
||||
// MARK: - Window
|
||||
|
||||
/// Before AppKit takes a full-screen window out of full screen to close it.
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
rememberFullScreen()
|
||||
return true
|
||||
}
|
||||
|
||||
/// One window is the whole app: closing it quits, as in System Settings.
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
rememberFullScreen()
|
||||
DispatchQueue.main.async { NSApp.terminate(nil) }
|
||||
}
|
||||
|
||||
func windowDidChangeOcclusionState(_ notification: Notification) {
|
||||
updateDisplayAwake(settings.keepDisplayAwake)
|
||||
}
|
||||
|
||||
/// Hold the display awake while a dashboard is actually on screen — not
|
||||
/// while the window is minimised, hidden or covered.
|
||||
private func updateDisplayAwake(_ keep: Bool) {
|
||||
let visible = window?.occlusionState.contains(.visible) == true
|
||||
let wanted = keep && visible && !order.isEmpty
|
||||
if wanted, awakeActivity == nil {
|
||||
awakeActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.idleDisplaySleepDisabled], reason: "Showing a ProdDash dashboard")
|
||||
} else if !wanted, let activity = awakeActivity {
|
||||
ProcessInfo.processInfo.endActivity(activity)
|
||||
awakeActivity = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View menu
|
||||
|
||||
@objc func goBackInPage(_ sender: Any?) { currentPage?.goBack() }
|
||||
@objc func goForwardInPage(_ sender: Any?) { currentPage?.goForward() }
|
||||
@objc func reloadPage(_ sender: Any?) { currentPage?.reload() }
|
||||
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
switch item.action {
|
||||
case #selector(goBackInPage(_:)): return currentPage?.webView.canGoBack == true
|
||||
case #selector(goForwardInPage(_:)): return currentPage?.webView.canGoForward == true
|
||||
case #selector(reloadPage(_:)): return currentPage != nil
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab bar as it shows in a window: the title bar's material, the tabs,
|
||||
/// and a hairline under them, floating over the top of the dashboard.
|
||||
final class TabBarOverlay: NSView {
|
||||
|
||||
init(model: TabBarModel) {
|
||||
super.init(frame: .zero)
|
||||
let material = NSVisualEffectView()
|
||||
material.material = .titlebar
|
||||
material.blendingMode = .withinWindow
|
||||
material.state = .followsWindowActiveState
|
||||
let tabs = TabBarHostingView(rootView: TabBar(model: model))
|
||||
tabs.sizingOptions = []
|
||||
let rule = Hairline()
|
||||
for view in [material, tabs, rule] {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(view)
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
material.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
material.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
material.topAnchor.constraint(equalTo: topAnchor),
|
||||
material.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
tabs.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
tabs.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
tabs.topAnchor.constraint(equalTo: topAnchor),
|
||||
tabs.bottomAnchor.constraint(equalTo: rule.topAnchor),
|
||||
rule.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
rule.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
rule.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
rule.heightAnchor.constraint(equalToConstant: 1),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("not used") }
|
||||
|
||||
/// The title bar's separator line. (An NSBox separator would do, but one
|
||||
/// laid out from a zero frame decides it's vertical and pins the window
|
||||
/// to a point wide.)
|
||||
private final class Hairline: NSView {
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
NSColor.separatorColor.setFill()
|
||||
bounds.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's content view. It watches the strip along the top of the
|
||||
/// window — which, with the content drawn under the title bar, includes the
|
||||
/// title bar — and says when the pointer comes and goes. Tracking stays on
|
||||
/// while another app is in front, so the bar answers the pointer even then.
|
||||
final class ViewerContentView: NSView {
|
||||
|
||||
var topZoneHeight: () -> CGFloat = { 0 }
|
||||
var onPointerInTopZone: (Bool) -> Void = { _ in }
|
||||
private var zone: NSTrackingArea?
|
||||
|
||||
private var zoneRect: NSRect {
|
||||
let height = min(topZoneHeight(), bounds.height)
|
||||
return NSRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
|
||||
}
|
||||
|
||||
var pointerInTopZone: Bool {
|
||||
guard let window else { return false }
|
||||
let rect = zoneRect
|
||||
return rect.height > 0 && rect.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let zone { removeTrackingArea(zone) }
|
||||
zone = nil
|
||||
let rect = zoneRect
|
||||
guard rect.height > 0 else { return }
|
||||
let area = NSTrackingArea(rect: rect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
|
||||
addTrackingArea(area)
|
||||
zone = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
if event.trackingArea === zone { onPointerInTopZone(true) }
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
if event.trackingArea === zone { onPointerInTopZone(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The window before any connection exists.
|
||||
struct EmptyStateView: View {
|
||||
|
||||
let add: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 96, height: 96)
|
||||
Text("No ProdDash Connections")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Add the address of a ProdDash installation and its dashboard opens here. Add more than one to switch between them.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 380)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Add Connection…", action: add)
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// ProdDash Viewer — a window onto ProdDash, and nothing else.
|
||||
let delegate = AppDelegate()
|
||||
let application = NSApplication.shared
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.regular)
|
||||
application.run()
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/bin/zsh
|
||||
# Build ProdDash Viewer.app — a window onto ProdDash installations, for Apple
|
||||
# silicon Macs. Native Swift, no dependencies.
|
||||
#
|
||||
# ./build.sh build/ProdDash Viewer.app
|
||||
# ./build.sh --dmg …and build/ProdDash-Viewer-<version>-arm64.dmg
|
||||
# ./build.sh --install …and copy it to /Applications
|
||||
# ./build.sh --run …and launch it
|
||||
# ./build.sh --notarize with --dmg: notarise and staple (needs a
|
||||
# Developer ID and a notarytool keychain profile)
|
||||
#
|
||||
# Signing works as ProdDash's own app does (launcher/macos/README.md): a
|
||||
# "Developer ID Application" certificate when one is installed (override with
|
||||
# CODESIGN_IDENTITY), ad-hoc when none is — fine on your own Macs, but another
|
||||
# Mac asks before it opens an ad-hoc app.
|
||||
#
|
||||
# Needs Xcode (xcode-select pointing at it): with the command line tools
|
||||
# alone, the macOS 27 SDK can't expand SwiftUI's @State macro.
|
||||
set -e -u
|
||||
SCRIPT="${0:A}"
|
||||
cd "${SCRIPT:h}"
|
||||
|
||||
APP_NAME="ProdDash Viewer"
|
||||
EXECUTABLE="ProdDashViewer"
|
||||
BUNDLE_ID="org.waterschurch.proddash.viewer"
|
||||
VERSION="1.0.0"
|
||||
ARCH="arm64" # Apple silicon
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
|
||||
OUT_DIR="build"
|
||||
APP="$OUT_DIR/$APP_NAME.app"
|
||||
CONTENTS="$APP/Contents"
|
||||
|
||||
INSTALL=0
|
||||
RUN=0
|
||||
MAKE_DMG=0
|
||||
NOTARIZE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--install) INSTALL=1 ;;
|
||||
--run) RUN=1 ;;
|
||||
--dmg) MAKE_DMG=1 ;;
|
||||
--notarize) NOTARIZE=1; MAKE_DMG=1 ;;
|
||||
-h|--help) sed -n '2,18p' "$SCRIPT"; exit 0 ;;
|
||||
*) echo "build.sh: unknown option $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v swiftc >/dev/null || {
|
||||
echo "build.sh: swiftc not found — install Xcode, then: sudo xcode-select -s /Applications/Xcode.app" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "$APP_NAME $VERSION — building for $ARCH"
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||
|
||||
echo " compiling"
|
||||
swiftc -O -whole-module-optimization \
|
||||
-target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \
|
||||
-module-name ProdDashViewer \
|
||||
-o "$CONTENTS/MacOS/$EXECUTABLE" \
|
||||
Sources/*.swift
|
||||
|
||||
cp Resources/Info.plist "$CONTENTS/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||
printf 'APPL????' > "$CONTENTS/PkgInfo"
|
||||
|
||||
cp -R Resources/Help "$CONTENTS/Resources/Help"
|
||||
|
||||
# Resources/AppIcon.png is drawn by Resources/make-icon.swift — ProdDash's
|
||||
# tile mark on a display, so the Viewer and ProdDash itself tell apart.
|
||||
echo " building the icon"
|
||||
ICONSET="$OUT_DIR/AppIcon.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z $size $size Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
double=$((size * 2))
|
||||
sips -z $double $double Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns"
|
||||
rm -rf "$ICONSET"
|
||||
find "$APP" -name '.DS_Store' -delete
|
||||
|
||||
# ── signing ──────────────────────────────────────────────────────────────
|
||||
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||
fi
|
||||
if [[ -n "$IDENTITY" ]]; then
|
||||
echo " signing as $IDENTITY"
|
||||
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||
else
|
||||
echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)"
|
||||
IDENTITY="-"
|
||||
SIGN=(--force --sign -)
|
||||
fi
|
||||
codesign "${SIGN[@]}" --identifier "$BUNDLE_ID" "$APP"
|
||||
codesign --verify --deep --strict "$APP" # stops the build if it doesn't
|
||||
echo " signature verifies"
|
||||
|
||||
echo "built $APP ($(du -sh "$APP" | awk '{print $1}'))"
|
||||
|
||||
# ── disk image ───────────────────────────────────────────────────────────
|
||||
if [[ $MAKE_DMG -eq 1 ]]; then
|
||||
DMG="$OUT_DIR/ProdDash-Viewer-$VERSION-$ARCH.dmg"
|
||||
STAGE="$OUT_DIR/dmg"
|
||||
rm -rf "$STAGE"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way
|
||||
hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \
|
||||
-ov -format UDZO -quiet "$DMG"
|
||||
rm -rf "$STAGE"
|
||||
[[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG"
|
||||
echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))"
|
||||
|
||||
if [[ $NOTARIZE -eq 1 ]]; then
|
||||
if [[ "$IDENTITY" == "-" ]]; then
|
||||
echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
PROFILE="${NOTARY_PROFILE:-proddash}"
|
||||
echo " notarising as keychain profile \"$PROFILE\" (a few minutes)"
|
||||
xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait
|
||||
xcrun stapler staple "$DMG"
|
||||
echo " notarised and stapled — this opens anywhere with no warning"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $INSTALL -eq 1 ]]; then
|
||||
DEST="/Applications/$APP_NAME.app"
|
||||
if pgrep -x "$EXECUTABLE" >/dev/null; then
|
||||
osascript -e "tell application id \"$BUNDLE_ID\" to quit" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
fi
|
||||
rm -rf "$DEST"
|
||||
cp -R "$APP" "$DEST"
|
||||
echo "installed $DEST"
|
||||
APP="$DEST"
|
||||
fi
|
||||
|
||||
[[ $RUN -eq 1 ]] && open "$APP"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user