Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5.5 8d8c0ee0c3 ProdDash Viewer 1.0.0: a Mac app that only shows ProdDash, with a tab per installation
A new Apple-silicon app in viewer/macos (Swift, no dependencies, ~1 MB): one
window showing a ProdDash dashboard in WebKit, and nothing else. It runs no
server; it points at any ProdDash on the network.

- Connections (name + address) in Settings; the add/edit sheet asks
  /api/modules whether ProdDash answers as you type. No port means 24500.
- Two or more connections bring a slim tab bar of their names. In a window
  it floats over the dashboard and comes out while the pointer is on the
  title bar; it is not part of the title bar, because anything there makes
  WebKit push the page down. In full screen it is a titlebar accessory that
  comes down with the menu bar, and it flashes up after each switch.
  ⌃Tab / ⌃⇧Tab and ⌘1–9 switch from the keyboard.
- Full screen, reopening in full screen if it quit there; window size and
  the selected tab are kept too. The dashboard's own full-screen button
  works as well.
- Menus: View (Back, Forward, Reload Page, Enter Full Screen), Settings,
  Edit (copy/paste in the page's text fields), Window, Help (a guide that
  ships in the app).
- Every installation keeps its own live web view. One that can't be
  reached says why and retries at 2, 4, 8, then every 15 s; a redirect off
  the installation is reported, not handed to the browser. JS alert,
  confirm and prompt show as sheets; links to other sites open in the
  browser.
- General settings: open at login (SMAppService), keep the display awake
  while a dashboard is on screen (default on).

build.sh signs with the Developer ID when one is installed and makes a
notarised DMG with --notarize, as the launcher's does. It needs Xcode: with
only the command line tools the macOS 27 SDK can't expand SwiftUI's @State.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:05:14 -04:00
christian c043834d1e Merge pull request 'Band Lineup 1.13.0: a photo per instrument, your own photos win, spots stay in reach while dragging' (#53) from claude/band-lineup-photos into main 2026-10-20 13:41:06 -04:00
christianandClaude Opus 5.5 1fd7dcdf1e Band Lineup 1.13.0: a photo per instrument, picked by the spot each person is in that week
DJ plays drums one week and sings the next; one photo can't fit both. A
person can now have a photo per instrument, named "Name - Instrument"
("DJ - Drums.jpg", "DJ - Vocals.jpg"), plus a plain "DJ.jpg" for
everything else. Sync reads the instrument family of the spot each person
lands in (its bracketed role, else their PCO position) and picks:

1. the photo picked by hand for them on that instrument;
2. a photo named for that instrument;
3. their everyday pick, else their best-named photo. A photo of them on
   another instrument still beats PCO's headshot.

Families keep the shorthands together: Vox/Vocals/Singer, Keys/Piano/Pad,
Drums/Perc, Bass, Guitar/Electric/Acoustic/Rhythm, Leader. A photo
dropped onto someone is remembered per person per instrument
("<id>|drums"), so a drum pick never overrides the vocal photo. A spot
naming several roles files under its most specific: Leader, then Vocals,
Drums, Keys, Bass, Guitar. Photo names from files now collapse the spaces
left by "Name - Instrument".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:40:00 -04:00
christianandClaude Opus 5.5 e59629694e Band Lineup 1.13.0: your own photos win, a photo dropped on someone becomes theirs, and the spots stay in reach while dragging
Photos. Sync fell back to the Planning Center headshot whenever two
library photos matched a person equally ("Mike" and "mike"), and had no
way to know "Rose" is Rosaliz — so the booth dragged those in by hand
every week. Now:
- Several equally good library photos is not a reason to use PCO's; the
  first of them is used. PCO's headshot is only for someone with no photo.
- Dropping a library photo onto the synced person already in that spot
  (same name, same first name, or a nickname sharing its first three
  letters) makes it their photo: they keep their Planning Center name and
  spot, and it is remembered (settings.json photoFor) for every later
  Sync. Photos already dragged in by hand for this plan are learned the
  same way on the next Sync.
- A synced person's library photo is read live, so replacing that photo
  updates the board without a re-sync.

Dragging. The editor was one scrolling column, so a photo found far down
the list had to be carried back up to its spot, and every drop redrew the
editor back to the top. The spots are now pinned in their own pane above
the photos, each pane auto-scrolls when a drag nears its edge, both keep
their scroll position through redraws, and a search box filters the
photos by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:28:49 -04:00
christian 6da6f4c31e Merge pull request 'Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red' (#52) from claude/vista-cues-wired-network into main 2026-10-20 13:27:42 -04:00
christianandClaude Opus 5.5 df2f0494f8 Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red
The Macs at NA are also on Wi-Fi and the Dante networks, and Bonjour lists each MIDI session once
per network. On 2026-10-20 the Tracks and Companion senders were set to Wi-Fi addresses, so the
lighting MIDI ProdDash hears rode Wi-Fi. With "Wired network" set (This machine; e.g. 10.1.0.0/16,
several with commas), a sender typed as a name or as an address outside it, and a session that
joins us from outside it, turn the admin status line and the tile's sender chip red, naming the
sender. It still joins, so the cues keep showing. Empty (the default) changes nothing; a typo in
the setting fails the module in /admin with a plain message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:14:44 -04:00
29 changed files with 2581 additions and 41 deletions
+3
View File
@@ -13,3 +13,6 @@ launcher/macos/build/
launcher/macos/.cache/
launcher/windows/build/
launcher/windows/.cache/
# the Viewer's build output
viewer/macos/build/
+5 -2
View File
@@ -4,10 +4,13 @@ Modular production dashboard web app for Waters Church, developed in this repo.
The app lives at the repo root and is built: zero-dependency Node server
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
none and installs them from the admin page's catalog), a native macOS menu-bar launcher in
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
the server and its own Node runtime, supervises them, and owns the macOS
permissions modules declare; see its README). Runtime state (module
permissions modules declare; see its README), and **ProdDash Viewer** in
`viewer/macos/` (Swift, no dependencies — a separate Apple-silicon app that
only displays one or more ProdDash installations in WebKit, with an
auto-hiding tab bar; it runs no server; see its README). Runtime state (module
config, named layouts) is written to a per-machine data directory outside the
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
kept") — never add code that writes runtime state into the repo folder.
+30 -2
View File
@@ -521,14 +521,26 @@ below. Nothing is installed without someone clicking.
A photo library fills it: point the module at a Google Drive folder shared
"anyone with the link" (file name → person's name) or at a folder on the
ProdDash machine, and photos added or removed there follow. **Edit** in the
title bar opens that library — drag a photo onto a slot (mouse, or press and
title bar opens that library — the spots pinned on top, the photos (with a
name search) scrolling below — drag a photo onto a slot (mouse, or press and
hold on a touchscreen), drag between slots to swap, rename or clear.
Dropping a photo onto the person already in a spot makes it *their* photo
for that instrument (a nickname like "Rose" for Rosaliz counts): they keep
their Planning Center name and spot, and every later Sync uses it whenever
they are on that instrument.
Planning Center is optional auto-fill on top, not the source of truth:
**Sync** reads the upcoming plan's team members and places each one. A
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
that slot — this is how the leader and vocalists, whose position is just
"Vocalist", land on the right mic — and everyone else goes to the slot
matching their position, one slot per person. Anything placed or moved by
matching their position, one slot per person. Photos come from the library
whenever it has one of them, and Planning Center's headshot only when it
has none. Someone who plays more than one thing can have a photo per
instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`;
Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and
a plain `DJ.jpg` for everything else: Sync uses the one for the spot they
are in that week. A photo dropped onto someone is remembered for that
instrument only. Anything placed or moved by
hand for that plan stays put through later syncs; last week's people are
replaced.
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
@@ -606,6 +618,19 @@ sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
the [module guide](docs/MODULE-GUIDE.md).
## ProdDash Viewer — a Mac that only shows the dashboard
For a Mac that should show ProdDash and nothing else — a booth display, a
second screen watching the other campus — there is **ProdDash Viewer**
([viewer/macos](viewer/macos)): one window, the dashboard, and a slim
auto-hiding tab bar when it is set up with more than one installation. It
runs no server of its own; point it at any ProdDash on the network. Full
screen, reopening as it was left, and keeping the display awake are built in.
```bash
cd viewer/macos && ./build.sh --dmg
```
## Install as a desktop web app
Open the page in a browser first, then:
@@ -636,6 +661,9 @@ ProdDash/
├── package.json name + version (what the update check compares)
├── config/ proddash.json defaults (runtime state lives in the
│ per-machine data directory — see "Configure")
├── launcher/macos/ the macOS app: menu-bar launcher carrying the server
│ and its own Node (launcher/windows: the Windows bundle)
├── viewer/macos/ ProdDash Viewer: a Mac app that only shows dashboards
├── docs/MODULE-GUIDE.md how to build and install a module
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
├── tools/prodcom-mock.js mock ProdCom API for development
+36 -12
View File
@@ -45,9 +45,10 @@ export default function create({ root, moduleApi }) {
moveDrag(p.x, p.y);
const tick = () => {
if (!drag) return;
const b = drag.body;
if (b && b.isConnected) {
const r = b.getBoundingClientRect(); const zone = 70; let dy = 0;
// Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
if (b && b.scrollHeight > b.clientHeight) {
const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
@@ -163,9 +164,9 @@ export default function create({ root, moduleApi }) {
if (drag) { renderHeld = true; return; }
stopConfetti();
stopParallax();
root.innerHTML = '';
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
if (editing) { renderEditor(); return; }
if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
root.innerHTML = '';
const board = el('div', 'bl-board');
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
@@ -240,11 +241,16 @@ export default function create({ root, moduleApi }) {
}
/* ---------- editor ---------- */
let libFilter = ''; // the photo search box, kept across re-renders
function renderEditor() {
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
// board on top and the picture picker (library) below it.
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
// that scroll on their own: the spots pinned on top, the photos below — so a photo found far
// down the list never has to be carried back up to its spot. Both panes keep their scroll
// position (and the search box its focus) when a board update redraws the editor.
const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
const wrap = el('div', 'bl-editor bl-editor-nobar');
const body = el('div', 'bl-editor-body');
const body = el('div', 'bl-editor-body bl-editor-split');
// Slots (drop targets)
const boardEl = el('div', 'bl-edit-board');
@@ -267,9 +273,15 @@ export default function create({ root, moduleApi }) {
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
boardEl.appendChild(cell);
}
body.appendChild(boardEl);
const boardPane = el('div', 'bl-pane bl-board-pane');
boardPane.appendChild(boardEl);
body.appendChild(boardPane);
// Library
// Library, with a name search
const libPane = el('div', 'bl-pane bl-lib-pane');
const search = el('input', 'bl-lib-search');
search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
libPane.appendChild(search);
const lib = el('div', 'bl-library');
const list = state?.library || [];
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
@@ -287,11 +299,23 @@ export default function create({ root, moduleApi }) {
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
lib.appendChild(item);
}
body.appendChild(lib);
const applyFilter = () => {
const q = libFilter.trim().toLowerCase();
for (const item of lib.querySelectorAll('.bl-lib-item')) {
const nm = item.querySelector('.bl-lib-name');
item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
}
};
search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
applyFilter();
libPane.appendChild(lib);
body.appendChild(libPane);
wrap.appendChild(body);
root.innerHTML = '';
root.appendChild(wrap);
boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
}
function assign(slotIndex, personId) {
@@ -320,7 +344,7 @@ export default function create({ root, moduleApi }) {
title: 'Sync',
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
});
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
// Columns −/+ (touch-friendly; replaces the gear number field).
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
+75 -9
View File
@@ -186,14 +186,60 @@ function assignMembersToSlots(slots, members) {
});
}
/* ── photos by instrument ──────────────────────────────────────────────
* A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg",
* "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo
* whose instrument matches the spot the person is in that week. Instruments group into families so
* the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass,
* Guitar/Electric/Acoustic/Rhythm, Leader. */
const ROLE_FAMILY = {
vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys',
drums: 'drums', percussion: 'drums', bass: 'bass',
guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader',
};
/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */
function roleFamilies(text) {
const out = new Set();
for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); }
return out;
}
/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */
function spotFamilies(slotLabel, positionLabel) {
const fromSlot = roleFamilies(slotRoleText(slotLabel));
return fromSlot.size ? fromSlot : roleFamilies(positionLabel || '');
}
/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}.
* The first word is always the person; instrument words after it are read off, other words
* ("action", "2") are just part of the photo's description. */
function photoParts(libName) {
const words = normalizeLabel(libName).split(' ').filter(Boolean);
const roles = new Set(); const person = [];
words.forEach((w, i) => {
const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null;
if (f) roles.add(f); else if (!roles.size) person.push(w);
});
return { person: person.join(' '), roles };
}
/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick
* never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under
* its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */
const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar'];
function photoKey(personId, families) {
const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || '';
return f ? `${personId}|${f}` : String(personId || '');
}
/**
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
* `entries` is [{ ...whatever, name }].
* preferring the most specific. Returns the entry, or null when no library photo is a confident
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
* not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
* `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the
* person is on this week: a photo for that instrument beats their everyday photo, which beats a
* photo of them on something else — but any photo of theirs still beats PCO's headshot.
*/
function bestLibraryMatch(pcoName, entries) {
function bestLibraryMatch(pcoName, entries, want) {
const target = normalizeLabel(pcoName);
if (!target) return null;
const T = target.split(' ').filter(Boolean);
@@ -225,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
if (last && last.length >= 3 && T.includes(last)) return 78;
return 0;
};
const wanted = want && want.size ? want : null;
let best = null;
let bestScore = 0;
let tie = false;
for (const e of entries) {
const s = scoreOf(e.name);
if (s > bestScore) { bestScore = s; best = e; tie = false; }
else if (s === bestScore && s > 0) tie = true;
const parts = photoParts(e.name);
let s = scoreOf(parts.roles.size ? parts.person : e.name);
if (s < 60) continue; // not them
if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20;
if (s > bestScore) { bestScore = s; best = e; }
}
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
if (!best) return null; // no confident match → PCO photo
return best;
}
/**
* Could this library photo be of this Planning Center person? Used when someone drops a photo onto
* a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
* A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
* first three letters (a nickname: "Rose" → "Rosaliz").
*/
function samePerson(pcoName, libName) {
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || '';
if (a && a === b) return true;
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
}
module.exports = {
samePerson,
roleFamilies,
spotFamilies,
photoParts,
photoKey,
normalizeTeamName,
isAllowedTeamName,
bestLibraryMatch,
+40 -2
View File
@@ -61,8 +61,9 @@ console.log('assignment:', JSON.stringify(out));
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
// exact full name beats a first-name photo
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
// tie → no match (two identical-scoring photos)
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
console.log('bestLibraryMatch: all assertions passed ✓');
}
@@ -103,3 +104,40 @@ console.log('assignment:', JSON.stringify(out));
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
console.log('notes: all assertions passed ✓');
}
// --- samePerson: is a dropped photo of the person already in the spot? ---
{
const { samePerson } = require('./matching');
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
console.log('samePerson: all assertions passed ✓');
}
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
{
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
assert.strictEqual(photoKey('dj', new Set()), 'dj');
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
console.log('photos by instrument: all assertions passed ✓');
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "band-lineup",
"name": "Band Lineup",
"version": "1.12.0",
"version": "1.13.0",
"proddash": ">=1.4.0",
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
"client": "client.js",
+54 -10
View File
@@ -32,7 +32,7 @@ const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
@@ -219,6 +219,27 @@ module.exports = {
const settings = readJsonFile(settingsPath, {}) || {};
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
// The photo the booth picked for a Planning Center person on an instrument ("<PCO person id>|drums"
// → library id; a bare person id is their everyday pick), learned when a library photo is dropped
// onto that person. Sync uses it before any name matching.
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
function rememberPhoto(key, libId) {
if (!key || !libId || settings.photoFor[key] === libId) return;
settings.photoFor[key] = libId; saveSettings();
}
// Library photo for a scheduled person in a spot whose instruments are `fams`:
// 1. the photo picked for them on this instrument;
// 2. a photo named for this instrument ("DJ - Drums");
// 3. their everyday pick, else their best-named photo (an other-instrument photo of them
// still beats PCO's headshot).
function libraryPhotoFor(personId, name, fams) {
const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null);
const forRole = live(settings.photoFor[photoKey(personId, fams)]);
if (forRole) return forRole;
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams);
if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m;
return live(settings.photoFor[personId]) || (m && m.file ? m : null);
}
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
const CHOIR_LABEL = 'CHOIR';
@@ -313,7 +334,7 @@ module.exports = {
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
*/
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums"
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
function scanFolder() {
if (!photoFolder) return false;
@@ -449,11 +470,13 @@ module.exports = {
if (!e) return null;
let name = '', file = '', birthday = '', manual = true, personId = '';
if (e.pco) {
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
// A synced person. Their library photo (if any) is read live, so replacing that photo
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
manual = false; personId = e.pco.personId || '';
name = String(e.pco.name || '').trim();
file = e.pco.file || '';
birthday = e.pco.birthday || '';
const lib = e.pco.libId ? library[e.pco.libId] : null;
file = (lib && lib.file) || e.pco.file || '';
birthday = e.pco.birthday || (lib && lib.birthday) || '';
} else if (e.personId) {
const lib = library[e.personId];
if (!lib) return null; // its library photo was deleted → slot is empty
@@ -521,7 +544,18 @@ module.exports = {
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
for (const m of members) m.birthday = bdays.get(m.personId) || '';
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
// A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
// Planning Center name, kept in that spot.
for (const [i, e] of Object.entries(board)) {
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
const libName = library[e.personId].name;
const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
const m = who[0];
rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId);
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan };
}
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
@@ -542,9 +576,10 @@ module.exports = {
for (let k = 0; k < openIdx.length; k += 1) {
const i = openIdx[k]; const m = assigned[k];
if (m) {
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
} else if (board[String(i)]) {
delete board[String(i)]; // synced or stale, and no longer on the plan
}
@@ -586,6 +621,8 @@ module.exports = {
streams, getLatest: () => latest, pco, listPlans, photoDir,
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
planId: () => (latest.plan && latest.plan.id) || '',
rememberPhoto,
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
photoUrlFor, libraryList,
setChoir, choirOn: () => choirOn,
};
@@ -694,7 +731,14 @@ module.exports = {
const personId = String(q.get('personId') || '').trim();
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
const here = current.board[slot];
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
// A photo of the person already in this spot: it's their photo now — this week and every
// Sync after. They keep their Planning Center name and their spot.
// Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone.
current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId);
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
else delete current.board[slot];
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
+14
View File
@@ -247,3 +247,17 @@
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
[data-module="band-lineup"] .bl-lib-search {
display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
}
[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }
+3 -1
View File
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
el.sources.hidden = !on('showSources') || !s.sources.length;
if (!el.sources.hidden) {
el.sources.innerHTML = s.sources.map((x) => `
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
<span class="vc-dot"></span>${esc(x.label)}
</span>`).join('');
}
@@ -186,6 +186,8 @@ export default function create({ root, moduleApi }) {
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
const connected = s.sources.filter((x) => x.connected);
const waiting = s.sources.filter((x) => !x.connected);
const off = s.sources.filter((x) => x.offNetwork);
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
if (!connected.length) {
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
return moduleApi.setStatus('error', why);
+8 -1
View File
@@ -1,7 +1,7 @@
{
"id": "vista-cues",
"name": "Vista Cues",
"version": "1.0.1",
"version": "1.1.0",
"proddash": ">=1.5.0",
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
"permissions": [
@@ -131,6 +131,13 @@
"default": true,
"group": "This machine",
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
},
"wiredNetwork": {
"type": "string",
"label": "Wired network",
"default": "",
"group": "This machine",
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
}
},
"configGroups": {
+58 -1
View File
@@ -49,6 +49,44 @@ function emptyState(extra = {}) {
};
}
/**
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
* session once per network, so a sender typed or picked from the wrong copy
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
* throws on a typo so the admin page says so at once.
*/
function parseNets(text) {
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
const parts = m ? m.slice(1, 5).map(Number) : [];
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
if (!m || parts.some((x) => x > 255) || bits > 32) {
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
}
return { base: ipNumber(parts.join('.')), bits, text: n };
});
}
function ipNumber(a) {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
}
/**
* Why an address is not on the wired network: '' when it is (or there is no
* rule), 'by name' for a host name (it resolves to whichever network answers
* first), 'not wired' for an address outside every wired network.
*/
function offNetwork(nets, address) {
if (!nets.length || !address) return '';
const v = ipNumber(address);
if (v === null) return 'by name';
if (address.startsWith('127.')) return '';
const span = (bits) => 2 ** (32 - bits);
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
}
function endpointOf(v) {
const ep = v && typeof v === 'object' ? v : {};
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
@@ -62,6 +100,8 @@ module.exports = {
const exclusive = config.exclusive === true;
const headTakesOver = config.headTakesOver !== false;
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
const nets = parseNets(config.wiredNetwork);
const netText = nets.map((n) => n.text).join(', ');
if (mappingErrors.length) {
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
}
@@ -187,6 +227,9 @@ module.exports = {
label: s.label,
name: p ? p.name : '',
address: inv?.address || s.host,
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
host: s.host,
configured: true,
connected: Boolean(p),
error: p ? '' : (inv?.error || ''),
@@ -195,7 +238,7 @@ module.exports = {
}
for (const p of session.participants.values()) {
if (seen.has(p.key) || !p.connected) continue;
out.push({ label: p.label || p.name, name: p.name, address: p.address, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
}
return out;
}
@@ -219,6 +262,7 @@ module.exports = {
sessionName,
port,
advertised,
wiredNetwork: netText,
sources: sources(),
current: cur || null,
// The rest in sheet order of their group, so a colour chip stays put as it changes.
@@ -304,6 +348,16 @@ module.exports = {
health() {
if (session.error) return { status: 'error', message: session.error };
const src = sources();
// Off the wired network: still joined (the cues keep working), but said first and in red.
const off = src.filter((s) => s.offNetwork);
if (off.length) {
return {
status: 'error',
message: `${off.map((s) => (s.offNetwork === 'by name'
? `${s.label} is set by name (${s.host})`
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
};
}
const on = src.filter((s) => s.connected);
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
const map = rules.length ? '' : ' · no cue lists mapped yet';
@@ -323,6 +377,9 @@ module.exports = {
};
},
// For the tests.
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
/**
* One picker entry, so the + menu gives Vista Cues a heading of its own —
* a module with no list sits headless under whichever module precedes it
+2
View File
@@ -180,6 +180,8 @@
}
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
@container (max-height: 140px) {
[data-module="vista-cues"] .vc-sources { display: none; }
}
+47
View File
@@ -363,6 +363,53 @@ async function freePair() {
await wait(80);
});
await test('wired network: addresses outside it, and names, are flagged', () => {
const mod = require('./server');
const off = mod._offNetwork;
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
});
await test('server entry: a sender off the wired network still joins, and says so', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake Tracks');
const port = await freePair();
const mod = require('./server');
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
const handle = mod.init({
config: {
sessionPort: port,
advertise: false,
wiredNetwork: '10.1.0.0/16',
source2Name: 'Tracks',
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
cueLists: '60 = Walk-in',
},
log: () => {},
});
await until(() => sender.peers.length > 0); // joined all the same
const h = handle.health();
assert.strictEqual(h.status, 'error');
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
const routes = mod.routes({});
let body = '';
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
const st = JSON.parse(body).state;
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
assert.strictEqual(st.sources[0].offNetwork, 'by name');
handle.stop();
sender.close();
await wait(80);
});
console.log(`\n${passed} passed`);
process.exit(0);
})().catch((e) => {
+121
View File
@@ -0,0 +1,121 @@
# ProdDash Viewer for macOS
A Mac app whose only job is to show ProdDash. It opens one installation's
dashboard in its own window — or several, with a slim tab bar to switch
between them — and does nothing else: no address bar, no bookmarks, no
browser to wander off in. It is for the screens that just *show* a dashboard:
a booth display, a stage-manager's laptop, a second Mac watching the other
campus.
It runs no server. ProdDash itself is installed with the menu-bar app in
[launcher/macos](../../launcher/macos); the Viewer points at that, or at any
other ProdDash on the network.
Apple silicon, macOS 13 or newer. Native Swift (AppKit with a little SwiftUI,
WebKit for the page), no dependencies, about 1 MB.
## Build
```bash
./build.sh # build/ProdDash Viewer.app
./build.sh --dmg # …and build/ProdDash-Viewer-<version>-arm64.dmg
./build.sh --install # …and copy it to /Applications
./build.sh --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
```
Needs Xcode, selected with `xcode-select` — with only the command line
tools, the macOS 27 SDK can't expand SwiftUI's `@State` macro and the
compile fails. Signing and notarising work exactly as
for ProdDash's own app — a Developer ID certificate when one is installed,
ad-hoc otherwise, and the same `proddash` notarytool profile; see
[launcher/macos/README.md](../../launcher/macos/README.md#signing). The
Viewer keeps its own version number (`VERSION` in `build.sh`), since it works
with any ProdDash rather than shipping with one.
The icon is `Resources/AppIcon.png`, drawn by `Resources/make-icon.swift` —
ProdDash's tile mark on a display, so the two apps tell apart in Finder.
Run `swift Resources/make-icon.swift Resources/AppIcon.png` to redraw it.
## Using it
**Settings…** (⌘,) → **Connections** → **+**: a name (what the tab says) and
the address you open ProdDash at in a browser, such as `10.3.11.152:24500`.
Without a port, ProdDash's 24500 is assumed; a path is kept, so an address
can open a particular page. The sheet asks the address whether ProdDash is
answering as you type, but saves either way — the installation may simply be
switched off right now. Double-click a connection to change it, drag to
reorder the tabs.
With two or more connections the tab bar appears. In a window it floats over
the top of the dashboard, comes out while the pointer is on the title bar and
tucks away after; in full screen it comes down with the menu bar, and shows
for a moment after each switch. ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 switch from the
keyboard. A tab marked ⚠︎ is an installation that isn't answering.
The menus are short on purpose: **View** has Back (⌘[), Forward (⌘]), Reload
Page (⌘R) and Enter Full Screen; the rest is what every Mac app has. **Edit**
is there because without it Copy and Paste do nothing in the dashboard's and
the admin page's text fields. **Help** opens the guide that ships in the app.
**Settings → General**: **Open at login**, and **Keep the display awake**
(on by default) — while a dashboard is on screen, the display doesn't dim or
sleep. Minimised, hidden or covered, it lets go.
## How it behaves
- **Every dashboard stays live.** Each connection has its own web view, kept
running behind the one on screen, so switching is instant and nothing has
to reconnect.
- **The page never jumps.** In a window the tab bar is an overlay rather than
part of the title bar: anything in the title bar makes WebKit push the
whole page down to clear it, and a dashboard re-flowing every time the bar
came and went would be worse than no bar. In full screen it *is* a title
bar accessory — there the title bar lives in a window of its own, and the
page stays put.
- **It comes back by itself.** An installation that can't be reached shows
why (nothing answering, can't be found, no network — including macOS
withholding Local Network access) and is retried at 2, 4, 8 and then every
15 seconds. Once a dashboard has loaded, ProdDash's own service worker and
reconnect logic carry it through a server restart.
- **It comes back as it was left.** The window's size and place, the tab that
was showing, and full screen: quit in full screen and it reopens in full
screen — so a booth Mac set to log in and open the Viewer at login returns
to the dashboard on its own after a restart.
- **It stays on ProdDash.** Links to the installation's own pages open in
the window; links anywhere else (a Planning Center item, the release notes)
open in the default browser, as do downloads. The page's `alert`,
`confirm` and `prompt` — the admin page confirms before deleting a layout,
Band Lineup prompts for names — show as sheets titled with the
installation's name.
- **The dashboard's own full-screen button works**, in addition to the
window's: it fills the screen with the dashboard alone; Esc leaves.
- Closing the window quits the app, as with System Settings — there is only
ever the one window.
- Safari's **Develop** menu can inspect a dashboard running in the Viewer.
## Files
```
Sources/
main.swift NSApplication, nothing else
AppDelegate.swift lifecycle and the menus
ViewerWindowController.swift the window: pages, tab bar overlay/accessory,
full screen, keyboard switching, display awake
DashboardPage.swift one connection's web view, status view, retry,
navigation policy, JavaScript dialogs
TabBar.swift the tabs (SwiftUI), shared by overlay and accessory
Connections.swift Connection, address rules, the store, the probe
ViewerSettings.swift preferences and the login item
SettingsWindowController.swift Settings: Connections and General
HelpWindowController.swift Help → ProdDash Viewer Help
Resources/
Info.plist bundle id org.waterschurch.proddash.viewer
Help/index.html the guide shown by the Help menu
AppIcon.png, make-icon.swift the icon and what draws it
```
Preferences live in `~/Library/Preferences/org.waterschurch.proddash.viewer.plist`
(connections, the selected tab, full screen, keep awake); the web views'
storage — each dashboard's own per-browser settings — in WebKit's default
store for the app.
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProdDash Viewer Help</title>
<style>
:root { color-scheme: light dark; --muted: #6e6e73; --rule: #d2d2d7; --key: #f2f2f7; }
@media (prefers-color-scheme: dark) { :root { --muted: #98989d; --rule: #3a3a3c; --key: #2c2c2e; } }
body { font: 13px/1.55 -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 28px 36px 48px; max-width: 640px; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 28px 0 6px; padding-top: 18px; border-top: 1px solid var(--rule); }
p, li { margin: 6px 0; }
ul { padding-left: 20px; }
.lead { color: var(--muted); margin-bottom: 18px; }
kbd { font: 12px -apple-system, sans-serif; background: var(--key); border: 1px solid var(--rule); border-radius: 4px; padding: 0 5px; }
code { font: 12px ui-monospace, Menlo, monospace; }
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
td { padding: 5px 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
td:first-child { width: 42%; white-space: nowrap; }
</style>
</head>
<body>
<h1>ProdDash Viewer</h1>
<p class="lead">A window onto ProdDash. It shows one installation’s dashboard — or several, with a tab for each — and nothing else.</p>
<h2>Add an installation</h2>
<ul>
<li>Choose <b>ProdDash Viewer → Settings…</b> (<kbd>⌘,</kbd>), then <b>Connections</b>, then <b>+</b>.</li>
<li><b>Name</b> is what its tab says — “AB Booth”, “NA Ingest”.</li>
<li><b>Address</b> is the one you open ProdDash at in a browser, such as <code>10.3.11.152:24500</code>. Leave the port off and ProdDash’s own, 24500, is used. The sheet tells you as you type whether ProdDash is answering there.</li>
<li>Double-click a connection to change it; drag connections to change the order of the tabs.</li>
</ul>
<h2>Switch between installations</h2>
<p>With two or more connections, a slim tab bar of their names sits under the title bar. It tucks itself away so the dashboard has the whole window: point at the title bar and it slides back out.</p>
<ul>
<li>In full screen, move the pointer to the top of the screen — the tab bar comes down with the menu bar. It also shows for a moment after each switch, so you can see where you are.</li>
<li><kbd>⌃Tab</kbd> and <kbd>⌃⇧Tab</kbd> go to the next and previous installation; <kbd>⌘1</kbd>–<kbd>⌘8</kbd> go straight to one, <kbd>⌘9</kbd> to the last.</li>
<li>Every dashboard stays live in the background, so switching is instant.</li>
<li>A tab with <b>⚠︎</b> is an installation that isn’t answering.</li>
</ul>
<h2>Full screen</h2>
<p>Click the green button, or choose <b>View → Enter Full Screen</b> (<kbd>fn F</kbd> or <kbd>⌃⌘F</kbd>). If the Viewer was in full screen when it quit, it opens in full screen again — so a booth Mac that restarts comes back as it was left.</p>
<p>The dashboard’s own full-screen button works too: it fills the screen with the dashboard alone, tab bar and all put away. Press <kbd>Esc</kbd> to leave it.</p>
<h2>Moving around</h2>
<ul>
<li><b>View → Back</b> (<kbd>⌘[</kbd>), <b>Forward</b> (<kbd>⌘]</kbd>) — after opening the admin page from the dashboard, for example. Swiping with two fingers works as in Safari.</li>
<li><b>View → Reload Page</b> (<kbd>⌘R</kbd>).</li>
<li>Links to anywhere outside the installation open in your web browser.</li>
</ul>
<h2>When an installation can’t be reached</h2>
<p>The Viewer says why — nothing answering, the address can’t be found, no network — and keeps trying by itself, so a dashboard comes back on its own after ProdDash restarts or its Mac reboots. <b>Try Again</b> asks right away.</p>
<p>If macOS asked whether ProdDash Viewer may find devices on your local network and the answer was no, it can’t reach anything: allow it in <b>System Settings → Privacy &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
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>ProdDash Viewer</string>
<key>CFBundleDisplayName</key>
<string>ProdDash Viewer</string>
<key>CFBundleExecutable</key>
<string>ProdDashViewer</string>
<key>CFBundleIdentifier</key>
<string>org.waterschurch.proddash.viewer</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 Waters Church</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSArchitecturePriority</key>
<array>
<string>arm64</string>
</array>
<key>NSHighResolutionCapable</key>
<true/>
<!-- ProdDash is served over plain http on the local network, and the
connection sheet asks each address whether ProdDash is there. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- macOS shows this sentence verbatim when it asks. -->
<key>NSLocalNetworkUsageDescription</key>
<string>ProdDash Viewer shows the dashboards of the ProdDash installations you add in Settings, which run on this network.</string>
</dict>
</plist>
+78
View File
@@ -0,0 +1,78 @@
// Draws AppIcon.png, the Viewer's icon: ProdDash's 2×2 tile mark on a
// display, so the Viewer and ProdDash itself tell apart in Finder and the
// Dock. Run it again only to change the icon; build.sh uses the PNG.
//
// swift Resources/make-icon.swift Resources/AppIcon.png
import AppKit
let side = 1024
let output = CommandLine.arguments.dropFirst().first ?? "AppIcon.png"
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0),
let context = NSGraphicsContext(bitmapImageRep: bitmap) else { fatalError("no bitmap") }
NSGraphicsContext.current = context
let g = context.cgContext
// Top-left origin, as the measurements below are written.
g.translateBy(x: 0, y: CGFloat(side))
g.scaleBy(x: 1, y: -1)
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255, green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
}
func rounded(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
}
func fill(_ path: CGPath, _ fillColor: CGColor) {
g.addPath(path); g.setFillColor(fillColor); g.fillPath()
}
// The body: Apple's icon grid — an 824-point rounded square, centred, on the
// soft shadow macOS icons sit on. ProdDash's own near-black.
let body = CGRect(x: 100, y: 100, width: 824, height: 824)
let bodyPath = rounded(body, 185)
g.saveGState()
g.setShadow(offset: CGSize(width: 0, height: 12), blur: 28, color: color(0x000000, 0.35))
fill(bodyPath, color(0x0b0e12))
g.restoreGState()
g.saveGState()
g.addPath(bodyPath); g.clip()
let shade = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB),
colors: [color(0x1b222b), color(0x0a0d11)] as CFArray, locations: [0, 1])!
g.drawLinearGradient(shade, start: CGPoint(x: 0, y: body.minY), end: CGPoint(x: 0, y: body.maxY), options: [])
g.restoreGState()
g.addPath(rounded(body.insetBy(dx: 1.5, dy: 1.5), 183.5))
g.setStrokeColor(color(0xffffff, 0.08)); g.setLineWidth(3); g.strokePath()
// The display: bezel, screen, neck and foot.
let bezel = CGRect(x: 212, y: 250, width: 600, height: 410)
let metal = color(0x3a434f)
fill(rounded(CGRect(x: 474, y: 650, width: 76, height: 84), 8), metal)
fill(rounded(CGRect(x: 372, y: 724, width: 280, height: 36), 18), color(0x4a5462))
fill(rounded(bezel, 44), metal)
let screen = bezel.insetBy(dx: 22, dy: 22)
fill(rounded(screen, 26), color(0x0c1015))
// ProdDash's mark on the screen: three outlined tiles and the lit one.
let padding: CGFloat = 46, gap: CGFloat = 30
let tileW = (screen.width - padding * 2 - gap) / 2
let tileH = (screen.height - padding * 2 - gap) / 2
for (column, row) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let tile = CGRect(x: screen.minX + padding + CGFloat(column) * (tileW + gap),
y: screen.minY + padding + CGFloat(row) * (tileH + gap),
width: tileW, height: tileH)
if column == 1 && row == 1 {
fill(rounded(tile, 22), color(0x2fe39a))
} else {
let inset = tile.insetBy(dx: 5, dy: 5)
fill(rounded(inset, 18), color(0x121820))
g.addPath(rounded(inset, 18)); g.setStrokeColor(color(0x3d4652)); g.setLineWidth(10); g.strokePath()
}
}
guard let png = bitmap.representation(using: .png, properties: [:]) else { fatalError("no png") }
try png.write(to: URL(fileURLWithPath: output))
print("wrote \(output)")
+110
View File
@@ -0,0 +1,110 @@
import AppKit
/**
ProdDash Viewer: one window showing a ProdDash dashboard, a tab per
installation when there's more than one, and Settings to say which.
The menus are deliberately short — Back, Forward, Reload Page and Settings,
plus what every Mac app has: the app menu, Edit (without which Copy and
Paste do nothing in the dashboard's text fields), Window and Help.
*/
final class AppDelegate: NSObject, NSApplicationDelegate {
static var version: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
private var viewer: ViewerWindowController!
private lazy var settingsWindow = SettingsWindowController()
private lazy var helpWindow = HelpWindowController()
func applicationWillFinishLaunching(_ notification: Notification) {
NSWindow.allowsAutomaticWindowTabbing = false
NSApp.mainMenu = makeMainMenu()
}
func applicationDidFinishLaunching(_ notification: Notification) {
viewer = ViewerWindowController()
viewer.onAddConnection = { [weak self] in self?.settingsWindow.show(addingConnection: true) }
viewer.showWindow(nil)
if ViewerSettings.shared.reopenInFullScreen {
DispatchQueue.main.async { self.viewer.window?.toggleFullScreen(nil) }
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !flag { viewer.showWindow(nil) }
return true
}
func applicationWillTerminate(_ notification: Notification) {
viewer?.rememberFullScreen()
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
@objc func showSettings(_ sender: Any?) { settingsWindow.show() }
@objc func showViewerHelp(_ sender: Any?) { helpWindow.show() }
// MARK: - Menus
private func makeMainMenu() -> NSMenu {
let name = "ProdDash Viewer"
let main = NSMenu()
let app = submenu(of: main, title: name)
app.addItem(withTitle: "About \(name)",
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
app.addItem(.separator())
app.addItem(withTitle: "Settings…", action: #selector(showSettings(_:)), keyEquivalent: ",")
app.addItem(.separator())
let services = submenu(of: app, title: "Services")
NSApp.servicesMenu = services
app.addItem(.separator())
app.addItem(withTitle: "Hide \(name)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
app.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)),
keyEquivalent: "h").keyEquivalentModifierMask = [.command, .option]
app.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
app.addItem(.separator())
app.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
let edit = submenu(of: main, title: "Edit")
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
edit.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
edit.addItem(.separator())
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
let view = submenu(of: main, title: "View")
view.addItem(withTitle: "Back", action: #selector(ViewerWindowController.goBackInPage(_:)), keyEquivalent: "[")
view.addItem(withTitle: "Forward", action: #selector(ViewerWindowController.goForwardInPage(_:)), keyEquivalent: "]")
view.addItem(withTitle: "Reload Page", action: #selector(ViewerWindowController.reloadPage(_:)), keyEquivalent: "r")
view.addItem(.separator())
// AppKit retitles this "Exit Full Screen" while the window is in full screen.
view.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)),
keyEquivalent: "f").keyEquivalentModifierMask = [.command, .control]
let window = submenu(of: main, title: "Window")
window.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
window.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
window.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
window.addItem(.separator())
window.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
NSApp.windowsMenu = window
let help = submenu(of: main, title: "Help")
help.addItem(withTitle: "\(name) Help", action: #selector(showViewerHelp(_:)), keyEquivalent: "?")
NSApp.helpMenu = help
return main
}
@discardableResult
private func submenu(of parent: NSMenu, title: String) -> NSMenu {
let menu = NSMenu(title: title)
parent.addItem(withTitle: title, action: nil, keyEquivalent: "").submenu = menu
return menu
}
}
+169
View File
@@ -0,0 +1,169 @@
import Foundation
import SwiftUI
/// One ProdDash installation the viewer shows: the name its tab carries and
/// the address it answers at.
struct Connection: Codable, Identifiable, Equatable {
var id = UUID()
var name: String
/// Always a full http(s) URL — see `Address.normalize`.
var address: String
var url: URL? { URL(string: address) }
/// The name, or the host when nobody gave it one.
var displayName: String {
let trimmed = name.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? (url?.host ?? address) : trimmed
}
var displayAddress: String { url.map(Address.display) ?? address }
}
enum Address {
/// ProdDash's port unless someone changed it (config/proddash.json).
static let defaultPort = 24500
/// What someone typed, as the URL ProdDash answers at: `10.3.11.152` and
/// `10.3.11.152:24500` both become `http://10.3.11.152:24500/`. A typed
/// scheme is taken at its word — `http://host` means port 80. A path is
/// kept, so an address can open a particular page.
static func normalize(_ text: String) -> URL? {
var typed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !typed.isEmpty else { return nil }
let hasScheme = typed.contains("://")
if !hasScheme { typed = "http://" + typed }
guard var parts = URLComponents(string: typed),
let scheme = parts.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = parts.host, !host.isEmpty else { return nil }
parts.scheme = scheme
if !hasScheme && parts.port == nil { parts.port = defaultPort }
if parts.path.isEmpty { parts.path = "/" }
return parts.url
}
/// Scheme, host and port — what decides whether a link stays in the viewer.
static func origin(_ url: URL) -> String? {
guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil }
let port = url.port ?? (scheme == "https" ? 443 : 80)
return "\(scheme)://\(host):\(port)"
}
/// For people to read: no trailing slash on a bare address.
static func display(_ url: URL) -> String {
var text = url.absoluteString
if url.path == "/" && url.query == nil && url.fragment == nil && text.hasSuffix("/") { text.removeLast() }
return text
}
/// Just the scheme, host and port, for people: `https://host`.
static func originDisplay(_ url: URL) -> String {
var parts = URLComponents()
parts.scheme = url.scheme
parts.host = url.host
parts.port = url.port
return parts.string ?? url.absoluteString
}
/// `host:port`, the way the error messages name a place.
static func place(_ url: URL?) -> String {
guard let url, let host = url.host else { return "that address" }
return url.port.map { "\(host):\($0)" } ?? host
}
}
/// The connections, in tab order, kept in the viewer's preferences.
final class ConnectionStore: ObservableObject {
static let shared = ConnectionStore()
private static let key = "connections"
@Published private(set) var connections: [Connection] {
didSet { save() }
}
private init() {
let saved = UserDefaults.standard.data(forKey: Self.key)
connections = saved.flatMap { try? JSONDecoder().decode([Connection].self, from: $0) } ?? []
}
func add(_ connection: Connection) { connections.append(connection) }
func update(_ connection: Connection) {
guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return }
connections[index] = connection
}
func remove(_ id: UUID) { connections.removeAll { $0.id == id } }
func move(from source: IndexSet, to destination: Int) {
connections.move(fromOffsets: source, toOffset: destination)
}
private func save() {
guard let data = try? JSONEncoder().encode(connections) else { return }
UserDefaults.standard.set(data, forKey: Self.key)
}
}
/// Network failures in words an operator can act on.
enum NetworkProblem {
static func describe(_ error: Error, url: URL?) -> String {
let ns = error as NSError
let place = Address.place(url)
guard ns.domain == NSURLErrorDomain else { return ns.localizedDescription }
switch ns.code {
case NSURLErrorCannotConnectToHost:
return "Nothing is answering at \(place). ProdDash may not be running there, or it may use another port."
case NSURLErrorTimedOut:
return "\(place) didn't answer in time."
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
return "\(url?.host ?? place) can't be found on the network."
case NSURLErrorNotConnectedToInternet:
return "This Mac isn't on a network — or macOS is keeping ProdDash Viewer off it. Check System Settings → Privacy & Security → Local Network."
case NSURLErrorNetworkConnectionLost:
return "The connection to \(place) dropped."
case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateNotYetValid:
return "\(place) offered a secure connection this Mac doesn't trust."
default:
return ns.localizedDescription
}
}
}
/// Asks an address whether ProdDash is there, for the connection sheet.
enum Probe {
enum Answer: Equatable {
case prodDash
case somethingElse
case noAnswer(String)
}
private static let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 4
config.timeoutIntervalForResource = 6
return URLSession(configuration: config)
}()
/// `/api/modules` is the first thing every dashboard asks for, and it
/// answers without a PIN — so `{"modules": …}` there means ProdDash.
static func ask(_ url: URL) async -> Answer {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return .somethingElse }
parts.path = "/api/modules"
parts.query = nil
parts.fragment = nil
guard let target = parts.url else { return .somethingElse }
do {
let (data, response) = try await session.data(from: target)
let ok = (response as? HTTPURLResponse)?.statusCode == 200
let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return ok && body?["modules"] != nil ? .prodDash : .somethingElse
} catch {
return .noAnswer(NetworkProblem.describe(error, url: url))
}
}
}
+425
View File
@@ -0,0 +1,425 @@
import AppKit
import SwiftUI
import WebKit
/**
One installation's dashboard: its web view, and what shows instead while the
installation can't be reached. Every page stays alive while the viewer runs,
so switching tabs is instant and the dashboards behind the one on screen stay
live.
*/
final class DashboardPage: NSObject, WKNavigationDelegate, WKUIDelegate {
final class Status: ObservableObject {
enum Phase: Equatable {
case connecting
case showing
case unreachable(String)
}
@Published var phase: Phase = .connecting
@Published var name = ""
@Published var address = ""
}
private(set) var connection: Connection
/// What the window shows: the web view, with the status view over it.
let view = NSView()
private(set) var webView: WKWebView
let status = Status()
/// Something the tab bar or the menus show has changed.
var onChange: () -> Void = {}
private let window: () -> NSWindow?
private var statusView: NSView!
private var retryTimer: Timer?
private var retryDelay: TimeInterval = 2
private var revealTimer: Timer?
private var lastAttempt: URL?
var isUnreachable: Bool {
if case .unreachable = status.phase { return true }
return false
}
init(connection: Connection, window: @escaping () -> NSWindow?) {
self.connection = connection
self.window = window
self.webView = Self.makeWebView()
super.init()
status.name = connection.displayName
status.address = connection.displayAddress
install(webView)
let overlay = NSHostingView(rootView: PageStatusView(status: status) { [weak self] in self?.retry() })
overlay.sizingOptions = [] // sized by the window, never the other way round
overlay.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlay)
pin(overlay)
statusView = overlay
}
private static func makeWebView() -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
// The dashboard's own full-screen button uses the Fullscreen API.
config.preferences.isElementFullscreenEnabled = true
// An alert may need to sound on a dashboard nobody has touched.
config.mediaTypesRequiringUserActionForPlayback = []
config.applicationNameForUserAgent = "ProdDashViewer/\(AppDelegate.version)"
let web = WKWebView(frame: .zero, configuration: config)
web.allowsBackForwardNavigationGestures = true
// Safari's Develop menu can inspect a dashboard running in here.
if #available(macOS 13.3, *) { web.isInspectable = true }
return web
}
private func install(_ web: WKWebView) {
web.navigationDelegate = self
web.uiDelegate = self
// Hidden until it has drawn something: no white flash on a dark booth
// display while the first load is in flight.
web.isHidden = true
web.translatesAutoresizingMaskIntoConstraints = false
if let statusView {
view.addSubview(web, positioned: .below, relativeTo: statusView)
} else {
view.addSubview(web)
}
pin(web)
}
private func pin(_ subview: NSView) {
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
subview.topAnchor.constraint(equalTo: view.topAnchor),
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
// MARK: - Loading
func load() { load(connection.url) }
private func load(_ url: URL?) {
guard let url else {
return show(.unreachable("“\(connection.address)” isn't an address ProdDash Viewer can open. Change it in Settings."))
}
lastAttempt = url
webView.load(URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 15))
}
/// Reload, or — while the installation can't be reached — try again now.
func reload() {
if isUnreachable || webView.url == nil { return retry() }
webView.reload()
}
func goBack() { webView.goBack() }
func goForward() { webView.goForward() }
func retry() {
retryTimer?.invalidate()
load(lastAttempt ?? connection.url)
}
/// An installation that isn't answering is usually one restarting — an
/// update, a reboot — so keep asking, less often the longer it takes.
private func scheduleRetry() {
retryTimer?.invalidate()
retryTimer = Timer.scheduledTimer(withTimeInterval: retryDelay, repeats: false) { [weak self] _ in
self?.retry()
}
retryDelay = min(retryDelay * 2, 15)
}
/// Settings changed this connection. A new address is a different
/// installation, so it gets a fresh web view — Back can't wander into the
/// old one.
func update(_ new: Connection) {
let moved = new.address != connection.address
connection = new
status.name = new.displayName
status.address = new.displayAddress
guard moved else { return onChange() }
retire(webView)
webView = Self.makeWebView()
install(webView)
retryTimer?.invalidate()
revealTimer?.invalidate()
retryDelay = 2
show(.connecting)
load()
}
/// The connection was removed.
func close() {
retryTimer?.invalidate()
revealTimer?.invalidate()
retire(webView)
view.removeFromSuperview()
}
private func retire(_ web: WKWebView) {
web.stopLoading()
web.navigationDelegate = nil
web.uiDelegate = nil
web.removeFromSuperview()
}
private func show(_ phase: Status.Phase) {
status.phase = phase
statusView.isHidden = phase == .showing
onChange()
}
private func isHome(_ url: URL) -> Bool {
guard let home = connection.url.flatMap(Address.origin) else { return false }
return Address.origin(url) == home
}
// MARK: - Navigation
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard webView === self.webView else { return }
reveal()
}
/// Normally the load finishes within moments of this; but a tile's frame
/// that never answers holds the load event back, and the dashboard
/// shouldn't sit behind "Connecting…" waiting for it.
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
guard webView === self.webView, status.phase != .showing else { return }
revealTimer?.invalidate()
revealTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self, weak webView] _ in
guard let self, let webView, webView === self.webView else { return }
self.reveal()
}
}
private func reveal() {
revealTimer?.invalidate()
retryTimer?.invalidate()
retryDelay = 2
webView.isHidden = false
if status.phase != .showing { show(.showing) }
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
failed(webView, error)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
failed(webView, error)
}
private func failed(_ web: WKWebView, _ error: Error) {
guard web === webView else { return }
let ns = error as NSError
// Not failures: a load replaced by another, or one handed to the browser.
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { return }
if ns.domain == "WebKitErrorDomain" && (ns.code == 102 || ns.code == 204) { return }
if let failing = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL, isHome(failing) {
lastAttempt = failing
}
show(.unreachable(NetworkProblem.describe(error, url: lastAttempt)))
scheduleRetry()
}
/// The page's process died (memory pressure, a WebKit crash). A booth
/// display is never left blank: bring it back — after the usual retry
/// delay, so a page that takes its process down as it loads can't spin.
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
guard webView === self.webView else { return }
if let url = webView.url, isHome(url) { lastAttempt = url }
scheduleRetry()
}
/// The viewer shows ProdDash and nothing else: the installation's own
/// pages open here, any other site opens in the browser.
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url else { return decisionHandler(.allow) }
// A tile may embed another site in a frame; only the page itself is policed.
guard action.targetFrame?.isMainFrame ?? true else { return decisionHandler(.allow) }
switch url.scheme?.lowercased() {
case "http", "https":
if !isHome(url) {
let followed = action.targetFrame == nil || status.phase == .showing
|| action.navigationType == .linkActivated || action.navigationType == .formSubmitted
if followed {
NSWorkspace.shared.open(url) // someone followed a link off ProdDash
} else {
redirected(to: url)
}
return decisionHandler(.cancel)
}
if action.targetFrame == nil {
// target="_blank" or window.open on ProdDash's own page: one
// window, so it opens here.
let request = action.request
DispatchQueue.main.async { webView.load(request) }
return decisionHandler(.cancel)
}
decisionHandler(.allow)
case "about", "blob", "data":
decisionHandler(.allow)
case "file":
// A file dropped somewhere the page didn't take it.
decisionHandler(.cancel)
default:
NSWorkspace.shared.open(url) // mailto: and the like
decisionHandler(.cancel)
}
}
/// Something the page can't show is a download: the browser does those.
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if response.isForMainFrame, !response.canShowMIMEType, let url = response.response.url {
NSWorkspace.shared.open(url)
return decisionHandler(.cancel)
}
decisionHandler(.allow)
}
/// The address itself sends the viewer somewhere else — a proxy, http
/// moving to https. Handing that to the browser would leave the page
/// stuck connecting, and do it again on every retry.
private func redirected(to url: URL) {
show(.unreachable("\(Address.place(connection.url)) sends ProdDash Viewer on to \(Address.originDisplay(url)). If that's where ProdDash answers, change the address in Settings to it."))
scheduleRetry()
}
/// window.open(), which doesn't pass through decidePolicyFor: ProdDash's
/// own pages open here, other sites in the browser. The viewer never
/// opens a second window.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
guard let url = action.request.url else { return nil }
if isHome(url) {
webView.load(action.request)
} else if url.scheme == "http" || url.scheme == "https" {
NSWorkspace.shared.open(url)
}
return nil
}
// MARK: - JavaScript dialogs and file pickers
// A WKWebView answers alert, confirm and prompt with "no" unless the app
// shows them — and the admin page confirms before deleting a layout, Band
// Lineup prompts for names.
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
let alert = dialog(message)
alert.addButton(withTitle: "OK")
present(alert) { _ in completionHandler() }
}
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
let alert = dialog(message)
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
present(alert) { completionHandler($0 == .alertFirstButtonReturn) }
}
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String,
defaultText: String?, initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (String?) -> Void) {
let alert = dialog(prompt)
let field = NSTextField(string: defaultText ?? "")
field.frame = NSRect(x: 0, y: 0, width: 280, height: 22)
alert.accessoryView = field
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = field
present(alert) { completionHandler($0 == .alertFirstButtonReturn ? field.stringValue : nil) }
}
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
if let window = window(), window.attachedSheet == nil {
panel.beginSheetModal(for: window) { completionHandler($0 == .OK ? panel.urls : nil) }
} else {
completionHandler(panel.runModal() == .OK ? panel.urls : nil)
}
}
/// Titled with the installation's name, so it's clear which one is asking.
private func dialog(_ message: String) -> NSAlert {
let alert = NSAlert()
alert.messageText = connection.displayName
alert.informativeText = message
return alert
}
private func present(_ alert: NSAlert, then done: @escaping (NSApplication.ModalResponse) -> Void) {
if let window = window(), window.attachedSheet == nil {
alert.beginSheetModal(for: window, completionHandler: done)
} else {
done(alert.runModal())
}
}
}
/// Over the web view while there is nothing to show yet, or while the
/// installation can't be reached.
struct PageStatusView: View {
@ObservedObject var status: DashboardPage.Status
let retry: () -> Void
/// A fast load shouldn't flash a spinner.
@State private var patient = false
var body: some View {
ZStack {
Color(nsColor: .windowBackgroundColor)
switch status.phase {
case .showing:
EmptyView()
case .connecting:
if patient {
VStack(spacing: 12) {
ProgressView()
Text("Connecting to \(status.name)…").foregroundStyle(.secondary)
}
}
case .unreachable(let why):
VStack(spacing: 10) {
Image(systemName: "network.slash")
.font(.system(size: 44, weight: .light))
.foregroundStyle(.secondary)
.padding(.bottom, 4)
Text("Can’t Reach \(status.name)")
.font(.title2.weight(.semibold))
Text(why)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
.fixedSize(horizontal: false, vertical: true)
Text(status.address)
.font(.callout.monospaced())
.foregroundStyle(.tertiary)
.textSelection(.enabled)
Button("Try Again", action: retry)
.controlSize(.large)
.padding(.top, 8)
Text("ProdDash Viewer keeps trying by itself.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(40)
}
}
.task {
try? await Task.sleep(for: .milliseconds(800))
patient = true
}
}
}
@@ -0,0 +1,42 @@
import AppKit
import WebKit
/// Help → ProdDash Viewer Help: the page that ships inside the app, so it
/// opens with no network and always matches this version.
final class HelpWindowController: NSWindowController, WKNavigationDelegate {
convenience init() {
let web = WKWebView()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 720),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.title = "ProdDash Viewer Help"
window.minSize = NSSize(width: 420, height: 360)
window.contentView = web
window.tabbingMode = .disallowed
window.isRestorable = false
window.center()
window.setFrameAutosaveName("ProdDashViewerHelp")
self.init(window: window)
web.navigationDelegate = self
if let page = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "Help") {
web.loadFileURL(page, allowingReadAccessTo: page.deletingLastPathComponent())
}
}
func show() {
showWindow(nil)
window?.makeKeyAndOrderFront(nil)
}
/// The help page itself stays here; its links to the web open in the browser.
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url, !url.isFileURL, url.scheme != "about" else {
return decisionHandler(.allow)
}
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
}
}
@@ -0,0 +1,299 @@
import AppKit
import SwiftUI
/// Settings (⌘,): a standard toolbar-tabbed settings window with two panes,
/// Connections and General. Every change applies as it's made.
final class SettingsWindowController: NSWindowController {
private var model: SettingsModel!
private var tabs: NSTabViewController!
convenience init() {
let model = SettingsModel()
let tabs = NSTabViewController()
tabs.tabStyle = .toolbar
tabs.addTabViewItem(Self.pane("Connections", symbol: "network",
ConnectionsPane(store: .shared, model: model)))
tabs.addTabViewItem(Self.pane("General", symbol: "gearshape",
GeneralPane(settings: .shared)))
let window = NSWindow(contentViewController: tabs)
window.styleMask = [.titled, .closable]
window.toolbarStyle = .preference
window.isRestorable = false
self.init(window: window)
self.model = model
self.tabs = tabs
window.center()
}
private static func pane<Content: View>(_ label: String, symbol: String, _ content: Content) -> NSTabViewItem {
let controller = NSHostingController(rootView: content)
controller.sizingOptions = .preferredContentSize
controller.title = label // the window takes its title from the pane
let item = NSTabViewItem(viewController: controller)
item.label = label
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
return item
}
/// Show Settings; `addingConnection` opens it on the add-a-connection sheet.
func show(addingConnection: Bool = false) {
if addingConnection {
tabs.selectedTabViewItemIndex = 0
model.pendingAdd = true
}
showWindow(nil)
window?.makeKeyAndOrderFront(nil)
}
}
final class SettingsModel: ObservableObject {
/// Set to open the add sheet as soon as the Connections pane can.
@Published var pendingAdd = false
}
// MARK: - Connections
private struct ConnectionDraft: Identifiable {
var id: UUID
var name: String
var address: String
var isNew: Bool
}
private struct ConnectionsPane: View {
@ObservedObject var store: ConnectionStore
@ObservedObject var model: SettingsModel
@State private var selection: UUID?
@State private var editing: ConnectionDraft?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Each connection is a ProdDash installation on your network. With more than one, a tab bar at the top of the window switches between them — drag them into the order you want.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
List(selection: $selection) {
ForEach(store.connections) { connection in
VStack(alignment: .leading, spacing: 2) {
Text(connection.displayName)
Text(connection.displayAddress)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 3)
.tag(connection.id)
}
.onMove { store.move(from: $0, to: $1) }
}
.listStyle(.bordered(alternatesRowBackgrounds: true))
.contextMenu(forSelectionType: UUID.self) { ids in
Button("Edit…") { edit(ids.first) }
.disabled(ids.count != 1)
Button("Remove") {
ids.forEach(store.remove)
if let selected = selection, ids.contains(selected) { selection = nil }
}
} primaryAction: { ids in
edit(ids.first)
}
.overlay {
if store.connections.isEmpty {
Text("No Connections").foregroundStyle(.tertiary)
}
}
.frame(height: 210)
HStack(spacing: 6) {
Button(action: add) {
Image(systemName: "plus").frame(width: 14)
}
.help("Add a connection")
.accessibilityLabel("Add")
Button(action: removeSelected) {
Image(systemName: "minus").frame(width: 14)
}
.help("Remove the selected connection")
.accessibilityLabel("Remove")
.disabled(selection == nil)
Spacer()
Button("Edit…") { edit(selection) }
.disabled(selection == nil)
}
}
.padding(20)
.frame(width: 520)
.sheet(item: $editing) { draft in
ConnectionEditor(draft: draft, onSave: save)
}
.onReceive(model.$pendingAdd) { pending in
guard pending else { return }
DispatchQueue.main.async {
model.pendingAdd = false
add()
}
}
}
private func add() {
editing = ConnectionDraft(id: UUID(), name: "", address: "", isNew: true)
}
private func edit(_ id: UUID?) {
guard let id, let connection = store.connections.first(where: { $0.id == id }) else { return }
editing = ConnectionDraft(id: id, name: connection.name, address: connection.displayAddress, isNew: false)
}
private func removeSelected() {
guard let id = selection else { return }
store.remove(id)
selection = nil
}
private func save(_ draft: ConnectionDraft) {
guard let url = Address.normalize(draft.address) else { return }
let connection = Connection(id: draft.id,
name: draft.name.trimmingCharacters(in: .whitespaces),
address: url.absoluteString)
if draft.isNew { store.add(connection) } else { store.update(connection) }
selection = connection.id
}
}
/// The add / edit sheet. It asks the address whether ProdDash is there as
/// you type, so a mistyped address shows up here rather than on the booth
/// display — but saving never depends on it: the installation may simply be
/// switched off right now.
private struct ConnectionEditor: View {
@State var draft: ConnectionDraft
let onSave: (ConnectionDraft) -> Void
@Environment(\.dismiss) private var dismiss
private enum Check: Equatable { case idle, checking, answer(Probe.Answer) }
@State private var check: Check = .idle
private var url: URL? { Address.normalize(draft.address) }
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text(draft.isNew ? "Add Connection" : "Edit Connection")
.font(.headline)
Form {
TextField("Name:", text: $draft.name, prompt: Text(url?.host ?? "AB Booth"))
TextField("Address:", text: $draft.address, prompt: Text("10.3.11.152:24500"))
LabeledContent {
status
.font(.caption)
.frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading)
} label: {
EmptyView()
}
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button(draft.isNew ? "Add" : "Save") {
onSave(draft)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(url == nil)
}
}
.padding(20)
.frame(width: 460)
.task(id: draft.address) {
guard let url else { return check = .idle }
check = .checking
try? await Task.sleep(for: .milliseconds(500)) // let the typing finish
guard !Task.isCancelled else { return }
let answer = await Probe.ask(url)
guard !Task.isCancelled else { return }
check = .answer(answer)
}
}
@ViewBuilder private var status: some View {
switch check {
case .idle where url == nil:
Text(draft.address.isEmpty
? "The address you open ProdDash at in a browser. Without a port, ProdDash’s \(String(Address.defaultPort)) is used."
: "That isn’t an address.")
.foregroundStyle(.secondary)
case .idle, .checking: // idle with an address: the check is about to start
HStack(spacing: 6) {
ProgressView().controlSize(.mini)
Text("Checking \(url.map(Address.display) ?? "")…").foregroundStyle(.secondary)
}
case .answer(.prodDash):
Label("ProdDash is answering at \(url.map(Address.display) ?? "").", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .answer(.somethingElse):
Label("Something answers at \(url.map(Address.display) ?? "") — but it isn’t ProdDash.", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
case .answer(.noAnswer(let why)):
Label(why, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
}
// MARK: - General
private struct GeneralPane: View {
@ObservedObject var settings: ViewerSettings
@State private var openAtLogin = LoginItem.isEnabled || LoginItem.needsApproval
@State private var needsApproval = LoginItem.needsApproval
@State private var loginProblem: String?
var body: some View {
Form {
Section {
Toggle("Open at login", isOn: Binding(
get: { openAtLogin },
set: { wanted in
loginProblem = LoginItem.set(wanted)
needsApproval = LoginItem.needsApproval
openAtLogin = LoginItem.isEnabled || needsApproval
}))
if needsApproval {
HStack {
Text("Waiting for your approval in System Settings.")
.foregroundStyle(.secondary)
Spacer()
Button("Open Login Items…") { LoginItem.openSystemSettings() }
}
}
if let loginProblem {
Text(loginProblem).foregroundStyle(.red)
}
}
Section {
Toggle(isOn: $settings.keepDisplayAwake) {
Text("Keep the display awake")
Text("While a dashboard is on screen, the display doesn’t dim or sleep.")
}
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.frame(width: 520, height: needsApproval || loginProblem != nil ? 200 : 152)
.onAppear(perform: refreshLoginItem)
// Approving it in System Settings happens elsewhere; catch up on return.
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in
refreshLoginItem()
}
}
private func refreshLoginItem() {
needsApproval = LoginItem.needsApproval
openAtLogin = LoginItem.isEnabled || needsApproval
}
}
+77
View File
@@ -0,0 +1,77 @@
import AppKit
import SwiftUI
/// What the tab bar shows: one tab per connection, in Settings' order.
final class TabBarModel: ObservableObject {
struct Tab: Identifiable, Equatable {
let id: UUID
var title: String
var address: String
var unreachable: Bool
}
@Published var tabs: [Tab] = []
@Published var selection: UUID?
var onSelect: (UUID) -> Void = { _ in }
}
/// Hosts the tabs. A click on a tab works even while another app is in
/// front, rather than only bringing the viewer forward.
final class TabBarHostingView: NSHostingView<TabBar> {
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
}
/// The slim bar of installation names. It sits in the window's title bar
/// area, so it takes the title bar's material in light and dark alike.
struct TabBar: View {
@ObservedObject var model: TabBarModel
var body: some View {
HStack(spacing: 4) {
ForEach(model.tabs) { tab in
TabButton(tab: tab, selected: tab.id == model.selection) { model.onSelect(tab.id) }
}
}
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct TabButton: View {
let tab: TabBarModel.Tab
let selected: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
HStack(spacing: 5) {
if tab.unreachable {
Image(systemName: "exclamationmark.triangle.fill")
.imageScale(.small)
.foregroundStyle(.orange)
}
Text(tab.title)
.lineLimit(1)
.truncationMode(.tail)
}
.font(.system(size: 12, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? .primary : .secondary)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 24)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(selected ? 0.12 : hovering ? 0.06 : 0))
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable(false) // the keyboard belongs to the dashboard; ⌃Tab switches
.onHover { hovering = $0 }
.help(tab.unreachable ? "\(tab.address) — not answering" : tab.address)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
import ServiceManagement
/// The viewer's own preferences. The connections themselves are in
/// ConnectionStore.
final class ViewerSettings: ObservableObject {
static let shared = ViewerSettings()
private let defaults = UserDefaults.standard
/// Keep the display on while a dashboard is on screen — a booth display
/// that dims halfway through a service is the failure this is here for.
@Published var keepDisplayAwake: Bool {
didSet { defaults.set(keepDisplayAwake, forKey: "keepDisplayAwake") }
}
private init() {
defaults.register(defaults: ["keepDisplayAwake": true])
keepDisplayAwake = defaults.bool(forKey: "keepDisplayAwake")
}
/// The window was in full screen when the viewer last quit, so it opens in
/// full screen again — a booth Mac that restarts comes back as it was left.
var reopenInFullScreen: Bool {
get { defaults.bool(forKey: "reopenInFullScreen") }
set { defaults.set(newValue, forKey: "reopenInFullScreen") }
}
/// The tab that was showing, so the viewer reopens on it.
var selectedConnection: UUID? {
get { defaults.string(forKey: "selectedConnection").flatMap(UUID.init(uuidString:)) }
set { defaults.set(newValue?.uuidString, forKey: "selectedConnection") }
}
}
/// "Open at login", through the macOS 13 login-item API. The operator may
/// still have to approve it in System Settings — hence `needsApproval`, which
/// Settings shows rather than failing silently.
enum LoginItem {
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
/// Nil when it worked; otherwise what went wrong, for Settings to show.
static func set(_ enabled: Bool) -> String? {
do {
if enabled {
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
} else {
if SMAppService.mainApp.status != .notRegistered { try SMAppService.mainApp.unregister() }
}
return nil
} catch {
return error.localizedDescription
}
}
static func openSystemSettings() {
SMAppService.openSystemSettingsLoginItems()
}
}
@@ -0,0 +1,544 @@
import AppKit
import Combine
import SwiftUI
/**
The viewer's one window: the selected installation's dashboard and — when
there is more than one — a slim tab bar of their names.
In a window the bar floats over the top of the dashboard, just under the
title bar: it comes out while the pointer is on the title bar and tucks away
after. It is deliberately not part of the title bar — anything there makes
WebKit push the whole page down to clear it, and a dashboard jumping every
time the bar came and went would be worse than no bar at all.
In full screen the bar *is* part of the title bar (a titlebar accessory), so
it comes down with the menu bar like any Mac app's toolbar; there the title
bar lives in a window of its own and the page stays put.
*/
final class ViewerWindowController: NSWindowController, NSWindowDelegate, NSMenuItemValidation {
static let tabBarHeight: CGFloat = 32
/// The standard title bar's height: where the dashboard starts.
private static let titlebarHeight: CGFloat = {
let probe = NSRect(x: 0, y: 0, width: 100, height: 100)
return NSWindow.frameRect(forContentRect: probe, styleMask: [.titled]).height - probe.height
}()
/// Empty state's "Add Connection…".
var onAddConnection: () -> Void = {}
private let store = ConnectionStore.shared
private let settings = ViewerSettings.shared
private var pages: [UUID: DashboardPage] = [:]
private var order: [UUID] = []
private var selection: UUID?
private let content = ViewerContentView()
private let pageArea = NSView()
private var pageAreaTop: NSLayoutConstraint!
private var emptyState: NSView!
private let tabModel = TabBarModel()
private var tabOverlay: TabBarOverlay!
private var tabOverlayTop: NSLayoutConstraint!
private let tabAccessory = NSTitlebarAccessoryViewController()
private var accessoryAttached = false
private var tabBarShown = false
private var hideTimer: Timer?
private var inFullScreen = false
private var leaving = false
private var awakeActivity: NSObjectProtocol?
private var keyMonitor: Any?
private var cancellables = Set<AnyCancellable>()
private var currentPage: DashboardPage? { selection.flatMap { pages[$0] } }
private var hasTabs: Bool { order.count > 1 }
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.title = "ProdDash Viewer"
window.minSize = NSSize(width: 640, height: 420)
window.tabbingMode = .disallowed // the tab bar here is ProdDash's, not AppKit's
window.collectionBehavior.insert(.fullScreenPrimary)
window.isRestorable = false // frame and full screen are kept below instead
window.center()
window.setFrameAutosaveName("ProdDashViewerWindow")
self.init(window: window)
window.delegate = self
build(in: window)
}
private func build(in window: NSWindow) {
// The content runs under the title bar so the pointer can be watched
// there; the dashboard itself starts below it.
window.contentView = content
pageArea.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(pageArea)
pageAreaTop = pageArea.topAnchor.constraint(equalTo: content.topAnchor, constant: Self.titlebarHeight)
let empty = NSHostingView(rootView: EmptyStateView { [weak self] in self?.onAddConnection() })
empty.sizingOptions = [] // sized by the window, never the other way round
empty.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(empty)
emptyState = empty
tabOverlay = TabBarOverlay(model: tabModel)
tabOverlay.translatesAutoresizingMaskIntoConstraints = false
tabOverlay.isHidden = true
tabOverlay.alphaValue = 0
content.addSubview(tabOverlay)
tabOverlayTop = tabOverlay.topAnchor.constraint(equalTo: content.topAnchor, constant: hiddenOverlayTop)
NSLayoutConstraint.activate([
pageAreaTop,
pageArea.leadingAnchor.constraint(equalTo: content.leadingAnchor),
pageArea.trailingAnchor.constraint(equalTo: content.trailingAnchor),
pageArea.bottomAnchor.constraint(equalTo: content.bottomAnchor),
empty.topAnchor.constraint(equalTo: pageArea.topAnchor),
empty.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
empty.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
empty.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
tabOverlayTop,
tabOverlay.leadingAnchor.constraint(equalTo: content.leadingAnchor),
tabOverlay.trailingAnchor.constraint(equalTo: content.trailingAnchor),
tabOverlay.heightAnchor.constraint(equalToConstant: Self.tabBarHeight),
])
let bar = TabBarHostingView(rootView: TabBar(model: tabModel))
bar.sizingOptions = []
bar.frame = NSRect(x: 0, y: 0, width: 800, height: Self.tabBarHeight)
tabAccessory.view = bar
tabAccessory.layoutAttribute = .bottom
tabAccessory.fullScreenMinHeight = 0 // out of sight until the menu bar comes down
tabModel.onSelect = { [weak self] id in self?.select(id) }
content.topZoneHeight = { [weak self] in self?.revealZoneHeight ?? 0 }
content.onPointerInTopZone = { [weak self] inside in self?.pointerInTopZone(inside) }
selection = settings.selectedConnection
// @Published sends before it stores, so use the value it sends.
store.$connections
.sink { [weak self] connections in self?.sync(connections) }
.store(in: &cancellables)
settings.$keepDisplayAwake
.sink { [weak self] keep in self?.updateDisplayAwake(keep) }
.store(in: &cancellables)
installKeyboardSwitching()
}
// MARK: - Connections and tabs
private func sync(_ connections: [Connection]) {
let ids = Set(connections.map(\.id))
let hadTabs = hasTabs
for (id, page) in pages where !ids.contains(id) {
page.close()
pages[id] = nil
}
for connection in connections {
if let page = pages[connection.id] {
page.update(connection)
} else {
let page = DashboardPage(connection: connection) { [weak self] in self?.window }
page.onChange = { [weak self, weak page] in
if let page { self?.pageChanged(page) }
}
pages[connection.id] = page
// A dashboard behind the one on screen still lays itself out
// at the window's size, not at nothing.
page.view.frame = NSRect(x: 0, y: 0, width: content.bounds.width,
height: max(content.bounds.height - pageAreaTop.constant, 0))
page.view.layoutSubtreeIfNeeded()
page.load()
}
}
order = connections.map(\.id)
if selection.map(ids.contains) != true { selection = order.first }
showSelection()
refreshTabs()
placeTabBar()
if hasTabs && !hadTabs { flashTabBar(for: 3) } // out long enough to be noticed
updateDisplayAwake(settings.keepDisplayAwake)
}
private func pageChanged(_ page: DashboardPage) {
refreshTabs()
if page === currentPage {
updateTitle()
// A new address means a new web view: put it on screen.
if page.view.superview !== pageArea { showSelection() }
// The web view can't take the keyboard until it's shown something.
if page.status.phase == .showing, (window?.firstResponder as? NSView)?.isDescendant(of: page.webView) != true {
window?.makeFirstResponder(page.webView)
}
}
}
private func showSelection() {
let page = currentPage
for view in pageArea.subviews where view !== page?.view { view.removeFromSuperview() }
if let page, page.view.superview !== pageArea {
page.view.translatesAutoresizingMaskIntoConstraints = false
pageArea.addSubview(page.view)
NSLayoutConstraint.activate([
page.view.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
page.view.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
page.view.topAnchor.constraint(equalTo: pageArea.topAnchor),
page.view.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
])
}
emptyState.isHidden = page != nil
if let page { window?.makeFirstResponder(page.webView) }
settings.selectedConnection = selection
updateTitle()
}
private func updateTitle() {
window?.title = currentPage?.connection.displayName ?? "ProdDash Viewer"
}
private func refreshTabs() {
tabModel.tabs = order.compactMap { id in
pages[id].map {
TabBarModel.Tab(id: id, title: $0.connection.displayName,
address: $0.connection.displayAddress, unreachable: $0.isUnreachable)
}
}
tabModel.selection = selection
}
func select(_ id: UUID) {
guard pages[id] != nil else { return }
if id != selection {
selection = id
showSelection()
refreshTabs()
}
flashTabBar(for: 2)
}
private func select(index: Int) {
guard order.indices.contains(index) else { return }
select(order[index])
}
private func step(_ delta: Int) {
guard !order.isEmpty else { return }
let index = selection.flatMap { order.firstIndex(of: $0) } ?? 0
select(index: (index + delta + order.count) % order.count)
}
/// ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 (⌘9 the last), as in Safari. Not menu items:
/// the menus stay as short as the viewer is.
private func installKeyboardSwitching() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, event.window === self.window, self.hasTabs else { return event }
// Only the keys that are pressed together — Caps Lock doesn't count.
let mods = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 48, mods.subtracting(.shift) == .control { // Tab
self.step(mods.contains(.shift) ? -1 : 1)
return nil
}
if mods == .command, let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...9).contains(digit) {
self.select(index: digit == 9 ? self.order.count - 1 : digit - 1)
return nil
}
return event
}
}
// MARK: - The tab bar coming and going
/// Overlay in a window, titlebar accessory in full screen — and neither
/// with only one connection. The accessory stays attached whenever there
/// are tabs, so it is already in place when AppKit builds the full-screen
/// title bar; in a window it is hidden, and hidden it takes no room, so
/// the page stays put.
private func placeTabBar() {
tabAccessory.isHidden = !inFullScreen
if hasTabs != accessoryAttached {
accessoryAttached = hasTabs
if hasTabs {
window?.addTitlebarAccessoryViewController(tabAccessory)
} else {
tabAccessory.removeFromParent()
}
}
if !hasTabs {
hideTimer?.invalidate()
setTabBar(shown: false, animated: false)
}
content.updateTrackingAreas()
}
/// Under the title bar in a window; the top of the screen in full screen,
/// where it only flashes up after a switch.
private var shownOverlayTop: CGFloat { inFullScreen ? 0 : Self.titlebarHeight }
/// Slid up by a few points while hidden, so it slides down as it fades in.
private var hiddenOverlayTop: CGFloat { shownOverlayTop - 8 }
/// The strip along the top that brings the bar out: the title bar, a
/// sliver of the dashboard below it, and the bar itself while it's out.
private var revealZoneHeight: CGFloat {
guard hasTabs, !inFullScreen else { return 0 }
return Self.titlebarHeight + (tabBarShown ? Self.tabBarHeight : 4)
}
private func pointerInTopZone(_ inside: Bool) {
if inside {
hideTimer?.invalidate()
setTabBar(shown: true)
} else {
scheduleHide(after: 0.8)
}
}
/// Bring the bar out for a moment — at launch, and after switching, so
/// it's clear what changed. In full screen too, where nothing else says
/// which installation is on screen.
private func flashTabBar(for seconds: TimeInterval) {
guard hasTabs else { return }
setTabBar(shown: true)
if !content.pointerInTopZone { scheduleHide(after: seconds) }
}
private func scheduleHide(after delay: TimeInterval) {
hideTimer?.invalidate()
hideTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
guard let self, !self.content.pointerInTopZone else { return }
self.setTabBar(shown: false)
}
}
private func setTabBar(shown: Bool, animated: Bool = true) {
let shown = shown && hasTabs
guard shown != tabBarShown else { return }
tabBarShown = shown
if shown { tabOverlay.isHidden = false }
content.layoutSubtreeIfNeeded()
NSAnimationContext.runAnimationGroup({ context in
context.duration = animated ? 0.18 : 0
context.allowsImplicitAnimation = true
tabOverlay.animator().alphaValue = shown ? 1 : 0
tabOverlayTop.animator().constant = shown ? shownOverlayTop : hiddenOverlayTop
content.layoutSubtreeIfNeeded()
}, completionHandler: { [weak self] in
guard let self, !self.tabBarShown else { return }
self.tabOverlay.isHidden = true
})
content.updateTrackingAreas()
}
// MARK: - Full screen
func windowWillEnterFullScreen(_ notification: Notification) {
settleOverlay()
inFullScreen = true
pageAreaTop.constant = 0
placeTabBar()
tabOverlayTop.constant = hiddenOverlayTop
}
func windowDidEnterFullScreen(_ notification: Notification) {
if !leaving { settings.reopenInFullScreen = true }
}
/// The accessory hides before the window is a window again — showing in
/// a window's title bar, it would push the dashboard down.
func windowWillExitFullScreen(_ notification: Notification) {
settleOverlay()
inFullScreen = false
pageAreaTop.constant = Self.titlebarHeight
placeTabBar()
tabOverlayTop.constant = hiddenOverlayTop
}
/// No overlay riding through the full-screen animation.
private func settleOverlay() {
hideTimer?.invalidate()
setTabBar(shown: false, animated: false)
}
func windowDidExitFullScreen(_ notification: Notification) {
if !leaving { settings.reopenInFullScreen = false }
content.updateTrackingAreas()
}
/// Called on the way out, before AppKit takes the window out of full
/// screen on its own account.
func rememberFullScreen() {
guard !leaving, let window else { return }
leaving = true
settings.reopenInFullScreen = window.styleMask.contains(.fullScreen)
}
// MARK: - Window
/// Before AppKit takes a full-screen window out of full screen to close it.
func windowShouldClose(_ sender: NSWindow) -> Bool {
rememberFullScreen()
return true
}
/// One window is the whole app: closing it quits, as in System Settings.
func windowWillClose(_ notification: Notification) {
rememberFullScreen()
DispatchQueue.main.async { NSApp.terminate(nil) }
}
func windowDidChangeOcclusionState(_ notification: Notification) {
updateDisplayAwake(settings.keepDisplayAwake)
}
/// Hold the display awake while a dashboard is actually on screen — not
/// while the window is minimised, hidden or covered.
private func updateDisplayAwake(_ keep: Bool) {
let visible = window?.occlusionState.contains(.visible) == true
let wanted = keep && visible && !order.isEmpty
if wanted, awakeActivity == nil {
awakeActivity = ProcessInfo.processInfo.beginActivity(
options: [.idleDisplaySleepDisabled], reason: "Showing a ProdDash dashboard")
} else if !wanted, let activity = awakeActivity {
ProcessInfo.processInfo.endActivity(activity)
awakeActivity = nil
}
}
// MARK: - View menu
@objc func goBackInPage(_ sender: Any?) { currentPage?.goBack() }
@objc func goForwardInPage(_ sender: Any?) { currentPage?.goForward() }
@objc func reloadPage(_ sender: Any?) { currentPage?.reload() }
func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(goBackInPage(_:)): return currentPage?.webView.canGoBack == true
case #selector(goForwardInPage(_:)): return currentPage?.webView.canGoForward == true
case #selector(reloadPage(_:)): return currentPage != nil
default: return true
}
}
}
/// The tab bar as it shows in a window: the title bar's material, the tabs,
/// and a hairline under them, floating over the top of the dashboard.
final class TabBarOverlay: NSView {
init(model: TabBarModel) {
super.init(frame: .zero)
let material = NSVisualEffectView()
material.material = .titlebar
material.blendingMode = .withinWindow
material.state = .followsWindowActiveState
let tabs = TabBarHostingView(rootView: TabBar(model: model))
tabs.sizingOptions = []
let rule = Hairline()
for view in [material, tabs, rule] {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
}
NSLayoutConstraint.activate([
material.leadingAnchor.constraint(equalTo: leadingAnchor),
material.trailingAnchor.constraint(equalTo: trailingAnchor),
material.topAnchor.constraint(equalTo: topAnchor),
material.bottomAnchor.constraint(equalTo: bottomAnchor),
tabs.leadingAnchor.constraint(equalTo: leadingAnchor),
tabs.trailingAnchor.constraint(equalTo: trailingAnchor),
tabs.topAnchor.constraint(equalTo: topAnchor),
tabs.bottomAnchor.constraint(equalTo: rule.topAnchor),
rule.leadingAnchor.constraint(equalTo: leadingAnchor),
rule.trailingAnchor.constraint(equalTo: trailingAnchor),
rule.bottomAnchor.constraint(equalTo: bottomAnchor),
rule.heightAnchor.constraint(equalToConstant: 1),
])
}
required init?(coder: NSCoder) { fatalError("not used") }
/// The title bar's separator line. (An NSBox separator would do, but one
/// laid out from a zero frame decides it's vertical and pins the window
/// to a point wide.)
private final class Hairline: NSView {
override func draw(_ dirtyRect: NSRect) {
NSColor.separatorColor.setFill()
bounds.fill()
}
}
}
/// The window's content view. It watches the strip along the top of the
/// window — which, with the content drawn under the title bar, includes the
/// title bar — and says when the pointer comes and goes. Tracking stays on
/// while another app is in front, so the bar answers the pointer even then.
final class ViewerContentView: NSView {
var topZoneHeight: () -> CGFloat = { 0 }
var onPointerInTopZone: (Bool) -> Void = { _ in }
private var zone: NSTrackingArea?
private var zoneRect: NSRect {
let height = min(topZoneHeight(), bounds.height)
return NSRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
}
var pointerInTopZone: Bool {
guard let window else { return false }
let rect = zoneRect
return rect.height > 0 && rect.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let zone { removeTrackingArea(zone) }
zone = nil
let rect = zoneRect
guard rect.height > 0 else { return }
let area = NSTrackingArea(rect: rect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
addTrackingArea(area)
zone = area
}
override func mouseEntered(with event: NSEvent) {
if event.trackingArea === zone { onPointerInTopZone(true) }
}
override func mouseExited(with event: NSEvent) {
if event.trackingArea === zone { onPointerInTopZone(false) }
}
}
/// The window before any connection exists.
struct EmptyStateView: View {
let add: () -> Void
var body: some View {
VStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 96, height: 96)
Text("No ProdDash Connections")
.font(.title2.weight(.semibold))
Text("Add the address of a ProdDash installation and its dashboard opens here. Add more than one to switch between them.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 380)
.fixedSize(horizontal: false, vertical: true)
Button("Add Connection…", action: add)
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.padding(.top, 6)
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
}
+8
View File
@@ -0,0 +1,8 @@
import AppKit
// ProdDash Viewer — a window onto ProdDash, and nothing else.
let delegate = AppDelegate()
let application = NSApplication.shared
application.delegate = delegate
application.setActivationPolicy(.regular)
application.run()
+149
View File
@@ -0,0 +1,149 @@
#!/bin/zsh
# Build ProdDash Viewer.app — a window onto ProdDash installations, for Apple
# silicon Macs. Native Swift, no dependencies.
#
# ./build.sh build/ProdDash Viewer.app
# ./build.sh --dmg …and build/ProdDash-Viewer-<version>-arm64.dmg
# ./build.sh --install …and copy it to /Applications
# ./build.sh --run …and launch it
# ./build.sh --notarize with --dmg: notarise and staple (needs a
# Developer ID and a notarytool keychain profile)
#
# Signing works as ProdDash's own app does (launcher/macos/README.md): a
# "Developer ID Application" certificate when one is installed (override with
# CODESIGN_IDENTITY), ad-hoc when none is — fine on your own Macs, but another
# Mac asks before it opens an ad-hoc app.
#
# Needs Xcode (xcode-select pointing at it): with the command line tools
# alone, the macOS 27 SDK can't expand SwiftUI's @State macro.
set -e -u
SCRIPT="${0:A}"
cd "${SCRIPT:h}"
APP_NAME="ProdDash Viewer"
EXECUTABLE="ProdDashViewer"
BUNDLE_ID="org.waterschurch.proddash.viewer"
VERSION="1.0.0"
ARCH="arm64" # Apple silicon
DEPLOYMENT_TARGET="13.0"
OUT_DIR="build"
APP="$OUT_DIR/$APP_NAME.app"
CONTENTS="$APP/Contents"
INSTALL=0
RUN=0
MAKE_DMG=0
NOTARIZE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--install) INSTALL=1 ;;
--run) RUN=1 ;;
--dmg) MAKE_DMG=1 ;;
--notarize) NOTARIZE=1; MAKE_DMG=1 ;;
-h|--help) sed -n '2,18p' "$SCRIPT"; exit 0 ;;
*) echo "build.sh: unknown option $1" >&2; exit 2 ;;
esac
shift
done
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install Xcode, then: sudo xcode-select -s /Applications/Xcode.app" >&2
exit 1
}
echo "$APP_NAME $VERSION — building for $ARCH"
rm -rf "$OUT_DIR"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
echo " compiling"
swiftc -O -whole-module-optimization \
-target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \
-module-name ProdDashViewer \
-o "$CONTENTS/MacOS/$EXECUTABLE" \
Sources/*.swift
cp Resources/Info.plist "$CONTENTS/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null
printf 'APPL????' > "$CONTENTS/PkgInfo"
cp -R Resources/Help "$CONTENTS/Resources/Help"
# Resources/AppIcon.png is drawn by Resources/make-icon.swift — ProdDash's
# tile mark on a display, so the Viewer and ProdDash itself tell apart.
echo " building the icon"
ICONSET="$OUT_DIR/AppIcon.iconset"
mkdir -p "$ICONSET"
for size in 16 32 128 256 512; do
sips -z $size $size Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
double=$((size * 2))
sips -z $double $double Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns"
rm -rf "$ICONSET"
find "$APP" -name '.DS_Store' -delete
# ── signing ──────────────────────────────────────────────────────────────
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo " signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)"
IDENTITY="-"
SIGN=(--force --sign -)
fi
codesign "${SIGN[@]}" --identifier "$BUNDLE_ID" "$APP"
codesign --verify --deep --strict "$APP" # stops the build if it doesn't
echo " signature verifies"
echo "built $APP ($(du -sh "$APP" | awk '{print $1}'))"
# ── disk image ───────────────────────────────────────────────────────────
if [[ $MAKE_DMG -eq 1 ]]; then
DMG="$OUT_DIR/ProdDash-Viewer-$VERSION-$ARCH.dmg"
STAGE="$OUT_DIR/dmg"
rm -rf "$STAGE"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way
hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \
-ov -format UDZO -quiet "$DMG"
rm -rf "$STAGE"
[[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG"
echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))"
if [[ $NOTARIZE -eq 1 ]]; then
if [[ "$IDENTITY" == "-" ]]; then
echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2
exit 1
fi
PROFILE="${NOTARY_PROFILE:-proddash}"
echo " notarising as keychain profile \"$PROFILE\" (a few minutes)"
xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait
xcrun stapler staple "$DMG"
echo " notarised and stapled — this opens anywhere with no warning"
fi
fi
if [[ $INSTALL -eq 1 ]]; then
DEST="/Applications/$APP_NAME.app"
if pgrep -x "$EXECUTABLE" >/dev/null; then
osascript -e "tell application id \"$BUNDLE_ID\" to quit" >/dev/null 2>&1 || true
sleep 1
fi
rm -rf "$DEST"
cp -R "$APP" "$DEST"
echo "installed $DEST"
APP="$DEST"
fi
[[ $RUN -eq 1 ]] && open "$APP"
exit 0