Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 6fffefc6c0 Overflow Tap: the tile says what Tap is on; the corner dot says how it went
The "TAP IS ON · since …" line (and its SWITCHING / NOT SWITCHED forms, and
the red error line under the destination) are gone from the tile. A tile
shows the keyword Tap is on, its destination, the groups moved, the slide
chip and the trail; status is the corner dot's job, as everywhere else in
ProdDash.

The dot now follows the retries honestly: a switch Overflow refuses is
retried three times, ten seconds apart, with the dot amber ("retrying (n of
4 attempts failed)") while the module is still fixing it itself; only when
the attempts are spent does the dot go red with the reason on hover, and the
alert fire if it is switched on. The budget counts refusals in a row across
keywords, not per keyword — a service keeps bringing new keywords, and each
one restarting a fresh budget meant a dead Overflow never turned the dot
red. The alert is raised once per outage, and a success clears the streak.
Verified against the mock with --fail-switch: amber through three refusals,
red on the fourth with one alert, later refusals stay red without
re-alerting, and green again on the first success once the mock is back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 13:57:45 -04:00
Waters Church 227e3bb846 Merge remote-tracking branch 'origin/main' into claude/overflow-tap-module
# Conflicts:
#	CLAUDE.md
#	README.md
2026-10-25 13:04:37 -04:00
Waters ChurchandClaude Fable 5.1 459d8b9c38 Overflow Tap: speak Overflow's Dashboard API, as its own helper app does
The first cut guessed a write route on Overflow's public API, which documents
only reads for Tap. With the helper app's bundle in hand (Overflow ProPresenter
Integration 1.2.3, read 2026-10-11) the module now makes exactly the calls the
helper makes, against the Dashboard's own backend:

  POST /nonprofit-dashboard/authentication/login/jwt      a Dashboard sign-in → JWT
  GET  /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords
                                                          the Keyword Mappings
  POST …/nonprofits/{id}/keywords?syncNonprofit=true      { keywords } = the switch

So the keyword → destination → Tap groups mapping stays where Overflow keeps
it (Dashboard › Integrations › ProPresenter › Keyword Mappings) and is read
from there: on start, every five minutes, on the tile's refresh button, and
from every trigger's answer. Admin config shrinks to the ProPresenter endpoint,
the Overflow Dashboard login (plus a Nonprofit ID only for an @overflow.co
staff login) and the alert switch; the API-key, Tap-groups, keyword-text and
match-mode fields are gone.

Behaviour follows the helper: both the presentation layer (/v1/status/slide)
and the announcement layer (/v1/announcement/slide_index + /active) are
watched; when a layer's set of matched keywords changes to a non-empty set it
is sent; matching is per word, case-insensitive, punctuation around the word
ignored. The session token is cached across re-inits for the same login so an
admin saving config repeatedly cannot trip Overflow's "too many attempts";
a 401 signs in again once; a refused sign-in is not retried within 20 s.

tools/overflow-tap-mock.js now plays the Dashboard API (any email/password
signs in; --fail-switch, --expire=<s>, --login=<email>:<password>) and gives
the mock ProPresenter an announcement layer (--no-announcements for an older
one). Verified against it: sign-in, keyword load, triggers on both layers,
token expiry → re-login, 500 → NOT SWITCHED + alert + retries, wrong login →
the reason on the tile and in the health line, and recovery. keywords.selftest.js
pins the matching rule (8 cases).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 10:25:02 -04:00
Waters ChurchandClaude Fable 5.1 6b51044be6 Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes
A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword Tap is on (and since when), its
destination and the groups moved, a chip saying whether the slide on screen
carries a keyword, and the last four switches. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.

Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.

The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.

Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:00:37 -04:00
26 changed files with 1804 additions and 2231 deletions
-3
View File
@@ -13,6 +13,3 @@ launcher/macos/build/
launcher/macos/.cache/
launcher/windows/build/
launcher/windows/.cache/
# the Viewer's build output
viewer/macos/build/
+3 -5
View File
@@ -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)
+32 -18
View File
@@ -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
+222
View File
@@ -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();
},
};
}
+66
View File
@@ -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 };
+76
View File
@@ -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}`);
+112
View File
@@ -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
}
}
}
+174
View File
@@ -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 };
+678
View File
@@ -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 });
},
};
},
};
+186
View File
@@ -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);
}
+255
View File
@@ -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)` : ''}`);
});
-121
View File
@@ -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

-78
View File
@@ -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 &amp; 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> &nbsp; <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> &nbsp; <kbd>⌃⌘F</kbd></td><td>Enter or leave full screen</td></tr>
<tr><td><kbd>⌃Tab</kbd> &nbsp; <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>
-44
View File
@@ -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>
-78
View File
@@ -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)")
-110
View File
@@ -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
}
}
-169
View File
@@ -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))
}
}
}
-425
View File
@@ -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
}
}
-77
View File
@@ -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 : [])
}
}
-61
View File
@@ -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))
}
}
-8
View File
@@ -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()
-149
View File
@@ -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