Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8c0ee0c3 |
@@ -13,3 +13,6 @@ launcher/macos/build/
|
|||||||
launcher/macos/.cache/
|
launcher/macos/.cache/
|
||||||
launcher/windows/build/
|
launcher/windows/build/
|
||||||
launcher/windows/.cache/
|
launcher/windows/.cache/
|
||||||
|
|
||||||
|
# the Viewer's build output
|
||||||
|
viewer/macos/build/
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ Modular production dashboard web app for Waters Church, developed in this repo.
|
|||||||
The app lives at the repo root and is built: zero-dependency Node server
|
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
|
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||||
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
|
`/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
|
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||||
the server and its own Node runtime, supervises them, and owns the macOS
|
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
|
config, named layouts) is written to a per-machine data directory outside the
|
||||||
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
||||||
kept") — never add code that writes runtime state into the repo folder.
|
kept") — never add code that writes runtime state into the repo folder.
|
||||||
|
|||||||
@@ -618,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
|
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).
|
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
|
## Install as a desktop web app
|
||||||
|
|
||||||
Open the page in a browser first, then:
|
Open the page in a browser first, then:
|
||||||
@@ -648,6 +661,9 @@ ProdDash/
|
|||||||
├── package.json name + version (what the update check compares)
|
├── package.json name + version (what the update check compares)
|
||||||
├── config/ proddash.json defaults (runtime state lives in the
|
├── config/ proddash.json defaults (runtime state lives in the
|
||||||
│ per-machine data directory — see "Configure")
|
│ 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/MODULE-GUIDE.md how to build and install a module
|
||||||
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
|
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
|
||||||
├── tools/prodcom-mock.js mock ProdCom API for development
|
├── tools/prodcom-mock.js mock ProdCom API for development
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# ProPresenter Look — a ProdDash module
|
|
||||||
|
|
||||||
Shows the audience **look** live in ProPresenter right now, as big as the
|
|
||||||
tile allows, so anyone away from the ProPresenter machine can tell at a glance
|
|
||||||
which look the outputs are on.
|
|
||||||
|
|
||||||
- The look's name fills the tile and **flashes** when it changes.
|
|
||||||
- **Modified** appears when someone changed the look by hand and it no longer
|
|
||||||
matches any saved look.
|
|
||||||
- **since 10:42 AM** says when the look last changed.
|
|
||||||
- If ProPresenter stops answering, the status dot goes red and the tile keeps
|
|
||||||
the **last known** look on screen, dimmed, until it comes back by itself.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
This folder is a standard ProdDash module (id `propresenter-looks`). Per the
|
|
||||||
ProdDash module guide, it installs one of two ways:
|
|
||||||
|
|
||||||
1. **From the admin page's catalog**, once the folder is in the ProdDash repo
|
|
||||||
at `modules/propresenter-looks/`: `/admin` → Available modules →
|
|
||||||
*ProPresenter Look* → Install.
|
|
||||||
2. **Drop it in**: copy this folder to the ProdDash data directory's
|
|
||||||
`modules/propresenter-looks/` and restart ProdDash.
|
|
||||||
- macOS: `~/Library/Application Support/ProdDash/modules/`
|
|
||||||
- Windows: `%APPDATA%\ProdDash\modules\`
|
|
||||||
|
|
||||||
Then add a tile from **+ Add tile → ProPresenter Look**.
|
|
||||||
|
|
||||||
## Configure (in `/admin`)
|
|
||||||
|
|
||||||
| Setting | What to enter |
|
|
||||||
| --- | --- |
|
|
||||||
| ProPresenter | Host / IP and port from ProPresenter's Settings › Network (Network enabled). |
|
|
||||||
| Password | Only if ProPresenter's network API asks for one. |
|
|
||||||
|
|
||||||
Each tile's gear has three switches: **Heading**, **Live status**, **Since**.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
The server entry polls ProPresenter's network API once a second
|
|
||||||
(`GET /v1/look/current`, plus `GET /v1/looks` every ten seconds to name the
|
|
||||||
saved look it matches) and streams the result to tiles over SSE. Browsers
|
|
||||||
never talk to ProPresenter. No dependencies. Needs ProdDash 1.5.0 or later and
|
|
||||||
ProPresenter 7.9+ (or 20+).
|
|
||||||
|
|
||||||
## Testing without ProPresenter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node tools/pp-looks-mock.js 1601
|
|
||||||
```
|
|
||||||
|
|
||||||
Point the module at host `127.0.0.1`, port `1601`. The live look changes every
|
|
||||||
8 seconds (`--every 3` for faster) and every fourth change is a hand-modified
|
|
||||||
look. `--password <pw>` demands a password; `--no-list` acts like a
|
|
||||||
ProPresenter without `GET /v1/looks`.
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
/* ProPresenter Look — client part.
|
|
||||||
|
|
||||||
The server part polls ProPresenter and streams the live audience look
|
|
||||||
here over SSE; the tile only renders. The look's name fills the tile so
|
|
||||||
it reads from across the booth, flashes once when it changes, and stays
|
|
||||||
on screen dimmed — marked "last known" — while ProPresenter is
|
|
||||||
unreachable. */
|
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
|
||||||
let view = null;
|
|
||||||
let feedOffline = false;
|
|
||||||
let stream = null;
|
|
||||||
let resizeObserver = null;
|
|
||||||
let flashTimer = null;
|
|
||||||
let lastName = null;
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="pl-screen">
|
|
||||||
<header class="pl-top">
|
|
||||||
<div class="pl-heading">Look</div>
|
|
||||||
<div class="pl-live"><span class="pl-live-dot"></span><span class="pl-live-text">Connecting…</span></div>
|
|
||||||
</header>
|
|
||||||
<div class="pl-name-box"><div class="pl-name is-waiting">Connecting…</div></div>
|
|
||||||
<footer class="pl-meta">
|
|
||||||
<span class="pl-tag" hidden>Modified</span>
|
|
||||||
<span class="pl-since" hidden></span>
|
|
||||||
</footer>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const screenEl = root.querySelector('.pl-screen');
|
|
||||||
const topEl = root.querySelector('.pl-top');
|
|
||||||
const headingEl = root.querySelector('.pl-heading');
|
|
||||||
const liveEl = root.querySelector('.pl-live');
|
|
||||||
const liveTextEl = root.querySelector('.pl-live-text');
|
|
||||||
const nameBox = root.querySelector('.pl-name-box');
|
|
||||||
const nameEl = root.querySelector('.pl-name');
|
|
||||||
const metaEl = root.querySelector('.pl-meta');
|
|
||||||
const tagEl = root.querySelector('.pl-tag');
|
|
||||||
const sinceEl = root.querySelector('.pl-since');
|
|
||||||
|
|
||||||
const on = (key) => moduleApi.instanceSettings[key] !== false;
|
|
||||||
|
|
||||||
function timeText(ms) {
|
|
||||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Biggest font size at which the name fits its box without breaking a word. */
|
|
||||||
function fitName() {
|
|
||||||
const MIN = 11;
|
|
||||||
const boxHeight = nameBox.clientHeight;
|
|
||||||
const boxWidth = nameBox.clientWidth;
|
|
||||||
nameEl.style.overflowWrap = 'normal';
|
|
||||||
if (boxHeight < MIN || !boxWidth) {
|
|
||||||
nameEl.style.fontSize = `${MIN}px`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fits = (size) => {
|
|
||||||
nameEl.style.fontSize = `${size}px`;
|
|
||||||
return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1;
|
|
||||||
};
|
|
||||||
let lo = MIN;
|
|
||||||
let hi = Math.max(MIN, Math.floor(boxHeight));
|
|
||||||
let best = MIN;
|
|
||||||
while (lo <= hi) {
|
|
||||||
const mid = (lo + hi) >> 1;
|
|
||||||
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
|
|
||||||
}
|
|
||||||
nameEl.style.fontSize = `${best}px`;
|
|
||||||
// One word too long even at MIN: let it break rather than overflow.
|
|
||||||
if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere';
|
|
||||||
}
|
|
||||||
|
|
||||||
function flash() {
|
|
||||||
screenEl.classList.remove('is-changed');
|
|
||||||
void screenEl.offsetWidth; // restart the animation
|
|
||||||
screenEl.classList.add('is-changed');
|
|
||||||
clearTimeout(flashTimer);
|
|
||||||
flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus() {
|
|
||||||
if (feedOffline) {
|
|
||||||
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
|
||||||
} else if (!view) {
|
|
||||||
moduleApi.setStatus('connecting', 'Connecting…');
|
|
||||||
} else if (!view.configured) {
|
|
||||||
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
|
||||||
} else if (view.reachable) {
|
|
||||||
moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`);
|
|
||||||
} else if (!view.updatedAt && !view.lastError) {
|
|
||||||
moduleApi.setStatus('connecting', 'Reaching ProPresenter…');
|
|
||||||
} else {
|
|
||||||
moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
setStatus();
|
|
||||||
const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError));
|
|
||||||
const look = view && view.look;
|
|
||||||
|
|
||||||
screenEl.classList.toggle('is-offline', offline);
|
|
||||||
headingEl.hidden = !on('showHeading');
|
|
||||||
liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows
|
|
||||||
topEl.hidden = headingEl.hidden && liveEl.hidden;
|
|
||||||
|
|
||||||
liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable));
|
|
||||||
liveEl.classList.toggle('is-down', offline);
|
|
||||||
if (feedOffline) liveTextEl.textContent = 'ProdDash offline';
|
|
||||||
else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable';
|
|
||||||
else if (view && view.reachable) liveTextEl.textContent = 'Live';
|
|
||||||
else liveTextEl.textContent = 'Connecting…';
|
|
||||||
|
|
||||||
let name;
|
|
||||||
let waiting = false;
|
|
||||||
if (look) {
|
|
||||||
name = look.name;
|
|
||||||
} else if (view && !view.configured) {
|
|
||||||
name = 'Set up in Admin';
|
|
||||||
waiting = true;
|
|
||||||
} else if (offline) {
|
|
||||||
name = 'ProPresenter unreachable';
|
|
||||||
waiting = true;
|
|
||||||
} else {
|
|
||||||
name = 'Connecting…';
|
|
||||||
waiting = true;
|
|
||||||
}
|
|
||||||
nameEl.textContent = name;
|
|
||||||
nameEl.classList.toggle('is-waiting', waiting);
|
|
||||||
|
|
||||||
if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash();
|
|
||||||
if (look) lastName = `${look.name}|${look.modified}`;
|
|
||||||
|
|
||||||
tagEl.hidden = !(look && look.modified);
|
|
||||||
const since = look && view.changedAt && on('showSince');
|
|
||||||
sinceEl.hidden = !since;
|
|
||||||
if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`;
|
|
||||||
metaEl.hidden = tagEl.hidden && sinceEl.hidden;
|
|
||||||
|
|
||||||
fitName();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
stream = moduleApi.sse('/stream', {
|
|
||||||
open() {
|
|
||||||
feedOffline = false;
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
error() {
|
|
||||||
feedOffline = true;
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
state(e) {
|
|
||||||
try {
|
|
||||||
view = JSON.parse(e.data);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
feedOffline = false;
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
start() {
|
|
||||||
render();
|
|
||||||
moduleApi.fetch('/state')
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.then((body) => {
|
|
||||||
if (body && body.state && !view) {
|
|
||||||
view = body.state;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
connect();
|
|
||||||
resizeObserver = new ResizeObserver(() => fitName());
|
|
||||||
resizeObserver.observe(nameBox);
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
if (stream) stream.close();
|
|
||||||
stream = null;
|
|
||||||
if (resizeObserver) resizeObserver.disconnect();
|
|
||||||
resizeObserver = null;
|
|
||||||
clearTimeout(flashTimer);
|
|
||||||
root.innerHTML = '';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "propresenter-looks",
|
|
||||||
"name": "ProPresenter Look",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"proddash": ">=1.5.0",
|
|
||||||
"description": "The audience look live in ProPresenter right now",
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"kind": "local-network",
|
|
||||||
"reason": "Reaching ProPresenter on this network"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"client": "client.js",
|
|
||||||
"server": "server.js",
|
|
||||||
"style": "style.css",
|
|
||||||
"minSize": {
|
|
||||||
"w": 2,
|
|
||||||
"h": 1
|
|
||||||
},
|
|
||||||
"defaultSize": {
|
|
||||||
"w": 4,
|
|
||||||
"h": 2
|
|
||||||
},
|
|
||||||
"configSchema": {
|
|
||||||
"propresenter": {
|
|
||||||
"type": "endpoint",
|
|
||||||
"label": "ProPresenter",
|
|
||||||
"default": {
|
|
||||||
"host": "",
|
|
||||||
"port": 1025
|
|
||||||
},
|
|
||||||
"help": "The host and port shown in ProPresenter's Settings › Network, with Network enabled."
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"type": "password",
|
|
||||||
"label": "Password",
|
|
||||||
"default": "",
|
|
||||||
"help": "Only if ProPresenter's network API asks for one. Leave empty otherwise."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"instanceSchema": {
|
|
||||||
"showHeading": {
|
|
||||||
"type": "switch",
|
|
||||||
"label": "Heading",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"showLive": {
|
|
||||||
"type": "switch",
|
|
||||||
"label": "Live status",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"showSince": {
|
|
||||||
"type": "switch",
|
|
||||||
"label": "Since",
|
|
||||||
"default": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ProPresenter Look — server part.
|
|
||||||
*
|
|
||||||
* Polls ProPresenter's network API for the live audience look and fans it
|
|
||||||
* out to every tile over SSE. Browsers never talk to ProPresenter directly.
|
|
||||||
*
|
|
||||||
* GET /state → { state: <view> } (instant paint for new tiles)
|
|
||||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
|
||||||
*
|
|
||||||
* ProPresenter's routes (ProPresenter 7.9+ / 20+):
|
|
||||||
* GET /v1/look/current → { id: { uuid, name, index }, screens: [...] }
|
|
||||||
* GET /v1/looks → [ { id: { uuid, name, index }, screens: [...] }, … ]
|
|
||||||
* The current look carries a UUID of its own, never one from /v1/looks. Its
|
|
||||||
* `index` points at the saved look it matches, or equals the number of saved
|
|
||||||
* looks when someone has changed it by hand and it matches none.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const POLL_MS = 1000; // the look matters the moment it changes
|
|
||||||
const RETRY_MS = 3000; // while ProPresenter is unreachable
|
|
||||||
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
|
|
||||||
const REQUEST_TIMEOUT_MS = 2500;
|
|
||||||
|
|
||||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
|
||||||
const HEARTBEAT_MS = 15000;
|
|
||||||
|
|
||||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
|
||||||
let current = null;
|
|
||||||
|
|
||||||
function emptyView(configured = false) {
|
|
||||||
return {
|
|
||||||
configured,
|
|
||||||
reachable: false,
|
|
||||||
lastError: '',
|
|
||||||
look: null, // { name, modified, screens }
|
|
||||||
changedAt: 0, // ms epoch the look last changed while we watched; 0 = unknown
|
|
||||||
updatedAt: 0, // ms epoch of the last good answer
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorText(err) {
|
|
||||||
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
|
|
||||||
function lookName(entry) {
|
|
||||||
return String(entry?.id?.name ?? entry?.name ?? '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
init({ config, log }) {
|
|
||||||
const streams = new Set();
|
|
||||||
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
|
||||||
const host = String(ep.host || '').trim();
|
|
||||||
const port = Number(ep.port) || 1025;
|
|
||||||
const password = String(config.password || '');
|
|
||||||
const configured = Boolean(host && port);
|
|
||||||
|
|
||||||
let view = emptyView(configured);
|
|
||||||
let lastFrame = '';
|
|
||||||
let saved = []; // names of the saved looks, in ProPresenter's order
|
|
||||||
let savedOk = false;
|
|
||||||
let tick = 0;
|
|
||||||
let stopped = false;
|
|
||||||
let pollTimer = null;
|
|
||||||
|
|
||||||
function url(target) {
|
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
|
||||||
if (password) built.searchParams.set('password', password);
|
|
||||||
return built;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJson(target) {
|
|
||||||
const response = await fetch(url(target), {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
|
|
||||||
err.status = response.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const text = await response.text();
|
|
||||||
return text ? JSON.parse(text) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcast() {
|
|
||||||
let frame;
|
|
||||||
try {
|
|
||||||
frame = JSON.stringify(view);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Polls land every second whether anything changed or not; only real
|
|
||||||
// changes go out, so idle tiles stay idle.
|
|
||||||
if (frame === lastFrame) return;
|
|
||||||
lastFrame = frame;
|
|
||||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
|
||||||
for (const res of streams) {
|
|
||||||
try {
|
|
||||||
res.write(payload);
|
|
||||||
} catch {
|
|
||||||
streams.delete(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSaved() {
|
|
||||||
try {
|
|
||||||
const list = await requestJson('/v1/looks');
|
|
||||||
saved = Array.isArray(list) ? list.map(lookName) : [];
|
|
||||||
savedOk = true;
|
|
||||||
} catch (err) {
|
|
||||||
// An older ProPresenter without the list still answers look/current;
|
|
||||||
// the live look's own name is shown then.
|
|
||||||
if (err.status !== 404) throw err;
|
|
||||||
saved = [];
|
|
||||||
savedOk = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLook(live) {
|
|
||||||
const index = Number(live?.id?.index);
|
|
||||||
const own = lookName(live);
|
|
||||||
const screens = Array.isArray(live?.screens) ? live.screens.length : 0;
|
|
||||||
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
|
|
||||||
return { name: saved[index] || own || `Look ${index + 1}`, modified: false, screens };
|
|
||||||
}
|
|
||||||
// Changed by hand, matching no saved look.
|
|
||||||
const modified = savedOk && Number.isInteger(index) && index >= saved.length;
|
|
||||||
return { name: own || (modified ? 'Custom look' : 'Unnamed look'), modified, screens };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollTick() {
|
|
||||||
if (stopped) return;
|
|
||||||
const next = { ...view };
|
|
||||||
try {
|
|
||||||
tick += 1;
|
|
||||||
if (tick % LOOKS_EVERY === 1 || !savedOk) await refreshSaved();
|
|
||||||
const live = await requestJson('/v1/look/current');
|
|
||||||
let look = resolveLook(live);
|
|
||||||
// A look saved since our last read: refresh the list now rather than
|
|
||||||
// calling it "Custom" for up to ten seconds.
|
|
||||||
if (look.modified && !view.look?.modified && tick % LOOKS_EVERY !== 1) {
|
|
||||||
await refreshSaved();
|
|
||||||
look = resolveLook(live);
|
|
||||||
}
|
|
||||||
const before = view.look;
|
|
||||||
if (before && (before.name !== look.name || before.modified !== look.modified)) {
|
|
||||||
next.changedAt = Date.now();
|
|
||||||
log(`look → ${look.name}${look.modified ? ' (modified)' : ''}`);
|
|
||||||
} else if (!before) {
|
|
||||||
log(`look is ${look.name}${look.modified ? ' (modified)' : ''}`);
|
|
||||||
}
|
|
||||||
next.look = look;
|
|
||||||
next.reachable = true;
|
|
||||||
next.lastError = '';
|
|
||||||
next.updatedAt = Date.now();
|
|
||||||
} catch (err) {
|
|
||||||
if (view.reachable) log(`ProPresenter unreachable: ${errorText(err)}`);
|
|
||||||
next.reachable = false;
|
|
||||||
next.lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
|
|
||||||
tick = 0; // re-read the saved looks first when it comes back
|
|
||||||
}
|
|
||||||
if (stopped) return;
|
|
||||||
view = next;
|
|
||||||
broadcast();
|
|
||||||
pollTimer = setTimeout(pollTick, view.reachable ? POLL_MS : RETRY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configured) {
|
|
||||||
log(`following the live look on ${host}:${port}`);
|
|
||||||
pollTick();
|
|
||||||
} else {
|
|
||||||
log('no ProPresenter host configured — set one in /admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
for (const res of streams) {
|
|
||||||
try {
|
|
||||||
res.write(': ping\n\n');
|
|
||||||
} catch {
|
|
||||||
streams.delete(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, HEARTBEAT_MS);
|
|
||||||
heartbeat.unref?.();
|
|
||||||
|
|
||||||
current = { streams, getView: () => view };
|
|
||||||
|
|
||||||
return {
|
|
||||||
stop() {
|
|
||||||
stopped = true;
|
|
||||||
clearTimeout(pollTimer);
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
for (const res of streams) {
|
|
||||||
try { res.end(); } catch { /* already gone */ }
|
|
||||||
}
|
|
||||||
streams.clear();
|
|
||||||
if (current && current.streams === streams) current = null;
|
|
||||||
},
|
|
||||||
health() {
|
|
||||||
if (!configured) return { status: 'error', message: 'No ProPresenter host configured' };
|
|
||||||
if (view.reachable) {
|
|
||||||
return { status: 'ok', message: `Live look on ${host}:${port}: ${view.look ? view.look.name : '—'}` };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: view.updatedAt || view.lastError ? 'error' : 'connecting',
|
|
||||||
message: view.lastError ? `ProPresenter unreachable: ${view.lastError}` : `Reaching ${host}:${port}…`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
routes() {
|
|
||||||
return {
|
|
||||||
'GET /state': (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
||||||
res.end(JSON.stringify({ state: current ? current.getView() : emptyView() }));
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /stream': (req, res) => {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
});
|
|
||||||
res.write('retry: 3000\n\n');
|
|
||||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getView() : emptyView())}\n\n`);
|
|
||||||
if (!current) return void res.end();
|
|
||||||
const { streams } = current;
|
|
||||||
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
|
||||||
// not queue frames forever; keepalive probes make the OS notice.
|
|
||||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
|
||||||
streams.add(res);
|
|
||||||
req.on('close', () => streams.delete(res));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/* ProPresenter Look — the live look's name, as big as the tile allows.
|
|
||||||
Colors come from the shell's variables only. */
|
|
||||||
|
|
||||||
[data-module="propresenter-looks"] .pl-screen {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
160deg,
|
|
||||||
color-mix(in srgb, var(--accent) 12%, transparent),
|
|
||||||
color-mix(in srgb, var(--bg) 60%, transparent)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── top row: heading and live status ── */
|
|
||||||
|
|
||||||
[data-module="propresenter-looks"] .pl-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-top[hidden],
|
|
||||||
[data-module="propresenter-looks"] .pl-heading[hidden],
|
|
||||||
[data-module="propresenter-looks"] .pl-live[hidden],
|
|
||||||
[data-module="propresenter-looks"] .pl-meta[hidden],
|
|
||||||
[data-module="propresenter-looks"] .pl-tag[hidden],
|
|
||||||
[data-module="propresenter-looks"] .pl-since[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="propresenter-looks"] .pl-heading {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="propresenter-looks"] .pl-live {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-live-dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--border);
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-live.is-live .pl-live-dot {
|
|
||||||
background: var(--accent);
|
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-dot {
|
|
||||||
background: var(--danger);
|
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 16%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-text {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── the look's name ── */
|
|
||||||
|
|
||||||
[data-module="propresenter-looks"] .pl-name-box {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-name {
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.05;
|
|
||||||
text-align: center;
|
|
||||||
white-space: normal;
|
|
||||||
word-break: normal;
|
|
||||||
hyphens: none;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-name.is-waiting {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unreachable: the last known look stays, visibly stale. */
|
|
||||||
[data-module="propresenter-looks"] .pl-screen.is-offline {
|
|
||||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-screen.is-offline .pl-name:not(.is-waiting) {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── footer: modified tag and when it changed ── */
|
|
||||||
|
|
||||||
[data-module="propresenter-looks"] .pl-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-looks"] .pl-tag {
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--warn);
|
|
||||||
background: color-mix(in srgb, var(--warn) 16%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A new look: one bright pulse so a glance catches the change. */
|
|
||||||
[data-module="propresenter-looks"] .pl-screen.is-changed {
|
|
||||||
animation: pl-changed 2.6s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes pl-changed {
|
|
||||||
0%, 20% { box-shadow: inset 0 0 0 3px var(--accent); background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
|
|
||||||
100% { box-shadow: inset 0 0 0 3px transparent; background-color: transparent; }
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock ProPresenter looks API, for developing the ProPresenter Look module
|
|
||||||
* without the real thing.
|
|
||||||
*
|
|
||||||
* node tools/pp-looks-mock.js [port] (default 1601)
|
|
||||||
* --every <seconds> how often the live look changes (default 8)
|
|
||||||
* --password <pw> demand ?password=<pw> like a locked ProPresenter
|
|
||||||
* --no-list answer 404 for /v1/looks (an older ProPresenter)
|
|
||||||
*
|
|
||||||
* Point the module at host 127.0.0.1 and this port. Every few changes the
|
|
||||||
* live look is "modified by hand", so it matches no saved look.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const flag = (name) => {
|
|
||||||
const i = args.indexOf(name);
|
|
||||||
return i >= 0 ? args[i + 1] : undefined;
|
|
||||||
};
|
|
||||||
const port = Number(args.find((a) => /^\d+$/.test(a))) || 1601;
|
|
||||||
const every = Number(flag('--every')) || 8;
|
|
||||||
const password = flag('--password') || '';
|
|
||||||
const noList = args.includes('--no-list');
|
|
||||||
|
|
||||||
const screen = (on) => ({ video_input: false, media: on, slide: on, announcements: on, props: on, messages: on, presentation: null, mask: null });
|
|
||||||
const saved = ['Normal', 'Lyrics Only', 'Sermon', 'Countdown', 'Video Only'].map((name, index) => ({
|
|
||||||
id: { uuid: crypto.randomUUID(), name, index },
|
|
||||||
screens: [screen(true), screen(index !== 4)],
|
|
||||||
}));
|
|
||||||
|
|
||||||
let live = 0;
|
|
||||||
let modified = false;
|
|
||||||
let changes = 0;
|
|
||||||
const liveUuid = crypto.randomUUID();
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
changes += 1;
|
|
||||||
modified = changes % 4 === 0;
|
|
||||||
if (!modified) live = (live + 1) % saved.length;
|
|
||||||
console.log(`live look → ${saved[live].id.name}${modified ? ' (modified)' : ''}`);
|
|
||||||
}, every * 1000);
|
|
||||||
|
|
||||||
function currentLook() {
|
|
||||||
return {
|
|
||||||
id: { uuid: liveUuid, name: saved[live].id.name, index: modified ? saved.length : live },
|
|
||||||
screens: saved[live].screens,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
http.createServer((req, res) => {
|
|
||||||
const url = new URL(req.url, 'http://x');
|
|
||||||
const send = (code, body) => {
|
|
||||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(body === undefined ? '' : JSON.stringify(body));
|
|
||||||
};
|
|
||||||
if (password && url.searchParams.get('password') !== password) return send(401, { error: 'password' });
|
|
||||||
if (url.pathname === '/version') return send(200, { name: 'Mock', platform: 'mac', os_version: '15', host_description: 'ProPresenter 21.0 (mock)' });
|
|
||||||
if (url.pathname === '/v1/looks') return noList ? send(404) : send(200, saved);
|
|
||||||
if (url.pathname === '/v1/look/current') return send(200, currentLook());
|
|
||||||
send(404);
|
|
||||||
}).listen(port, () => {
|
|
||||||
console.log(`mock ProPresenter looks on http://127.0.0.1:${port} — live look changes every ${every}s`);
|
|
||||||
console.log(`live look → ${saved[live].id.name}`);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# ProdDash Viewer for macOS
|
||||||
|
|
||||||
|
A Mac app whose only job is to show ProdDash. It opens one installation's
|
||||||
|
dashboard in its own window — or several, with a slim tab bar to switch
|
||||||
|
between them — and does nothing else: no address bar, no bookmarks, no
|
||||||
|
browser to wander off in. It is for the screens that just *show* a dashboard:
|
||||||
|
a booth display, a stage-manager's laptop, a second Mac watching the other
|
||||||
|
campus.
|
||||||
|
|
||||||
|
It runs no server. ProdDash itself is installed with the menu-bar app in
|
||||||
|
[launcher/macos](../../launcher/macos); the Viewer points at that, or at any
|
||||||
|
other ProdDash on the network.
|
||||||
|
|
||||||
|
Apple silicon, macOS 13 or newer. Native Swift (AppKit with a little SwiftUI,
|
||||||
|
WebKit for the page), no dependencies, about 1 MB.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh # build/ProdDash Viewer.app
|
||||||
|
./build.sh --dmg # …and build/ProdDash-Viewer-<version>-arm64.dmg
|
||||||
|
./build.sh --install # …and copy it to /Applications
|
||||||
|
./build.sh --run # …and open it
|
||||||
|
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs Xcode, selected with `xcode-select` — with only the command line
|
||||||
|
tools, the macOS 27 SDK can't expand SwiftUI's `@State` macro and the
|
||||||
|
compile fails. Signing and notarising work exactly as
|
||||||
|
for ProdDash's own app — a Developer ID certificate when one is installed,
|
||||||
|
ad-hoc otherwise, and the same `proddash` notarytool profile; see
|
||||||
|
[launcher/macos/README.md](../../launcher/macos/README.md#signing). The
|
||||||
|
Viewer keeps its own version number (`VERSION` in `build.sh`), since it works
|
||||||
|
with any ProdDash rather than shipping with one.
|
||||||
|
|
||||||
|
The icon is `Resources/AppIcon.png`, drawn by `Resources/make-icon.swift` —
|
||||||
|
ProdDash's tile mark on a display, so the two apps tell apart in Finder.
|
||||||
|
Run `swift Resources/make-icon.swift Resources/AppIcon.png` to redraw it.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
**Settings…** (⌘,) → **Connections** → **+**: a name (what the tab says) and
|
||||||
|
the address you open ProdDash at in a browser, such as `10.3.11.152:24500`.
|
||||||
|
Without a port, ProdDash's 24500 is assumed; a path is kept, so an address
|
||||||
|
can open a particular page. The sheet asks the address whether ProdDash is
|
||||||
|
answering as you type, but saves either way — the installation may simply be
|
||||||
|
switched off right now. Double-click a connection to change it, drag to
|
||||||
|
reorder the tabs.
|
||||||
|
|
||||||
|
With two or more connections the tab bar appears. In a window it floats over
|
||||||
|
the top of the dashboard, comes out while the pointer is on the title bar and
|
||||||
|
tucks away after; in full screen it comes down with the menu bar, and shows
|
||||||
|
for a moment after each switch. ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 switch from the
|
||||||
|
keyboard. A tab marked ⚠︎ is an installation that isn't answering.
|
||||||
|
|
||||||
|
The menus are short on purpose: **View** has Back (⌘[), Forward (⌘]), Reload
|
||||||
|
Page (⌘R) and Enter Full Screen; the rest is what every Mac app has. **Edit**
|
||||||
|
is there because without it Copy and Paste do nothing in the dashboard's and
|
||||||
|
the admin page's text fields. **Help** opens the guide that ships in the app.
|
||||||
|
|
||||||
|
**Settings → General**: **Open at login**, and **Keep the display awake**
|
||||||
|
(on by default) — while a dashboard is on screen, the display doesn't dim or
|
||||||
|
sleep. Minimised, hidden or covered, it lets go.
|
||||||
|
|
||||||
|
## How it behaves
|
||||||
|
|
||||||
|
- **Every dashboard stays live.** Each connection has its own web view, kept
|
||||||
|
running behind the one on screen, so switching is instant and nothing has
|
||||||
|
to reconnect.
|
||||||
|
- **The page never jumps.** In a window the tab bar is an overlay rather than
|
||||||
|
part of the title bar: anything in the title bar makes WebKit push the
|
||||||
|
whole page down to clear it, and a dashboard re-flowing every time the bar
|
||||||
|
came and went would be worse than no bar. In full screen it *is* a title
|
||||||
|
bar accessory — there the title bar lives in a window of its own, and the
|
||||||
|
page stays put.
|
||||||
|
- **It comes back by itself.** An installation that can't be reached shows
|
||||||
|
why (nothing answering, can't be found, no network — including macOS
|
||||||
|
withholding Local Network access) and is retried at 2, 4, 8 and then every
|
||||||
|
15 seconds. Once a dashboard has loaded, ProdDash's own service worker and
|
||||||
|
reconnect logic carry it through a server restart.
|
||||||
|
- **It comes back as it was left.** The window's size and place, the tab that
|
||||||
|
was showing, and full screen: quit in full screen and it reopens in full
|
||||||
|
screen — so a booth Mac set to log in and open the Viewer at login returns
|
||||||
|
to the dashboard on its own after a restart.
|
||||||
|
- **It stays on ProdDash.** Links to the installation's own pages open in
|
||||||
|
the window; links anywhere else (a Planning Center item, the release notes)
|
||||||
|
open in the default browser, as do downloads. The page's `alert`,
|
||||||
|
`confirm` and `prompt` — the admin page confirms before deleting a layout,
|
||||||
|
Band Lineup prompts for names — show as sheets titled with the
|
||||||
|
installation's name.
|
||||||
|
- **The dashboard's own full-screen button works**, in addition to the
|
||||||
|
window's: it fills the screen with the dashboard alone; Esc leaves.
|
||||||
|
- Closing the window quits the app, as with System Settings — there is only
|
||||||
|
ever the one window.
|
||||||
|
- Safari's **Develop** menu can inspect a dashboard running in the Viewer.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
main.swift NSApplication, nothing else
|
||||||
|
AppDelegate.swift lifecycle and the menus
|
||||||
|
ViewerWindowController.swift the window: pages, tab bar overlay/accessory,
|
||||||
|
full screen, keyboard switching, display awake
|
||||||
|
DashboardPage.swift one connection's web view, status view, retry,
|
||||||
|
navigation policy, JavaScript dialogs
|
||||||
|
TabBar.swift the tabs (SwiftUI), shared by overlay and accessory
|
||||||
|
Connections.swift Connection, address rules, the store, the probe
|
||||||
|
ViewerSettings.swift preferences and the login item
|
||||||
|
SettingsWindowController.swift Settings: Connections and General
|
||||||
|
HelpWindowController.swift Help → ProdDash Viewer Help
|
||||||
|
Resources/
|
||||||
|
Info.plist bundle id org.waterschurch.proddash.viewer
|
||||||
|
Help/index.html the guide shown by the Help menu
|
||||||
|
AppIcon.png, make-icon.swift the icon and what draws it
|
||||||
|
```
|
||||||
|
|
||||||
|
Preferences live in `~/Library/Preferences/org.waterschurch.proddash.viewer.plist`
|
||||||
|
(connections, the selected tab, full screen, keep awake); the web views'
|
||||||
|
storage — each dashboard's own per-browser settings — in WebKit's default
|
||||||
|
store for the app.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ProdDash Viewer Help</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; --muted: #6e6e73; --rule: #d2d2d7; --key: #f2f2f7; }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { --muted: #98989d; --rule: #3a3a3c; --key: #2c2c2e; } }
|
||||||
|
body { font: 13px/1.55 -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 28px 36px 48px; max-width: 640px; }
|
||||||
|
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 15px; margin: 28px 0 6px; padding-top: 18px; border-top: 1px solid var(--rule); }
|
||||||
|
p, li { margin: 6px 0; }
|
||||||
|
ul { padding-left: 20px; }
|
||||||
|
.lead { color: var(--muted); margin-bottom: 18px; }
|
||||||
|
kbd { font: 12px -apple-system, sans-serif; background: var(--key); border: 1px solid var(--rule); border-radius: 4px; padding: 0 5px; }
|
||||||
|
code { font: 12px ui-monospace, Menlo, monospace; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
|
||||||
|
td { padding: 5px 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
||||||
|
td:first-child { width: 42%; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>ProdDash Viewer</h1>
|
||||||
|
<p class="lead">A window onto ProdDash. It shows one installation’s dashboard — or several, with a tab for each — and nothing else.</p>
|
||||||
|
|
||||||
|
<h2>Add an installation</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Choose <b>ProdDash Viewer → Settings…</b> (<kbd>⌘,</kbd>), then <b>Connections</b>, then <b>+</b>.</li>
|
||||||
|
<li><b>Name</b> is what its tab says — “AB Booth”, “NA Ingest”.</li>
|
||||||
|
<li><b>Address</b> is the one you open ProdDash at in a browser, such as <code>10.3.11.152:24500</code>. Leave the port off and ProdDash’s own, 24500, is used. The sheet tells you as you type whether ProdDash is answering there.</li>
|
||||||
|
<li>Double-click a connection to change it; drag connections to change the order of the tabs.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Switch between installations</h2>
|
||||||
|
<p>With two or more connections, a slim tab bar of their names sits under the title bar. It tucks itself away so the dashboard has the whole window: point at the title bar and it slides back out.</p>
|
||||||
|
<ul>
|
||||||
|
<li>In full screen, move the pointer to the top of the screen — the tab bar comes down with the menu bar. It also shows for a moment after each switch, so you can see where you are.</li>
|
||||||
|
<li><kbd>⌃Tab</kbd> and <kbd>⌃⇧Tab</kbd> go to the next and previous installation; <kbd>⌘1</kbd>–<kbd>⌘8</kbd> go straight to one, <kbd>⌘9</kbd> to the last.</li>
|
||||||
|
<li>Every dashboard stays live in the background, so switching is instant.</li>
|
||||||
|
<li>A tab with <b>⚠︎</b> is an installation that isn’t answering.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Full screen</h2>
|
||||||
|
<p>Click the green button, or choose <b>View → Enter Full Screen</b> (<kbd>fn F</kbd> or <kbd>⌃⌘F</kbd>). If the Viewer was in full screen when it quit, it opens in full screen again — so a booth Mac that restarts comes back as it was left.</p>
|
||||||
|
<p>The dashboard’s own full-screen button works too: it fills the screen with the dashboard alone, tab bar and all put away. Press <kbd>Esc</kbd> to leave it.</p>
|
||||||
|
|
||||||
|
<h2>Moving around</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>View → Back</b> (<kbd>⌘[</kbd>), <b>Forward</b> (<kbd>⌘]</kbd>) — after opening the admin page from the dashboard, for example. Swiping with two fingers works as in Safari.</li>
|
||||||
|
<li><b>View → Reload Page</b> (<kbd>⌘R</kbd>).</li>
|
||||||
|
<li>Links to anywhere outside the installation open in your web browser.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>When an installation can’t be reached</h2>
|
||||||
|
<p>The Viewer says why — nothing answering, the address can’t be found, no network — and keeps trying by itself, so a dashboard comes back on its own after ProdDash restarts or its Mac reboots. <b>Try Again</b> asks right away.</p>
|
||||||
|
<p>If macOS asked whether ProdDash Viewer may find devices on your local network and the answer was no, it can’t reach anything: allow it in <b>System Settings → Privacy & Security → Local Network</b>.</p>
|
||||||
|
|
||||||
|
<h2>General settings</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>Open at login</b> — start the Viewer when this Mac’s user logs in. macOS may ask you to approve it in System Settings first.</li>
|
||||||
|
<li><b>Keep the display awake</b> — while a dashboard is on screen, the display doesn’t dim or sleep. On unless you turn it off.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Keyboard shortcuts</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td><kbd>⌘,</kbd></td><td>Settings</td></tr>
|
||||||
|
<tr><td><kbd>⌘[</kbd> <kbd>⌘]</kbd></td><td>Back, Forward</td></tr>
|
||||||
|
<tr><td><kbd>⌘R</kbd></td><td>Reload Page</td></tr>
|
||||||
|
<tr><td><kbd>fn F</kbd> <kbd>⌃⌘F</kbd></td><td>Enter or leave full screen</td></tr>
|
||||||
|
<tr><td><kbd>⌃Tab</kbd> <kbd>⌃⇧Tab</kbd></td><td>Next, previous installation</td></tr>
|
||||||
|
<tr><td><kbd>⌘1</kbd> … <kbd>⌘9</kbd></td><td>An installation by its place in the tab bar</td></tr>
|
||||||
|
<tr><td><kbd>⌘W</kbd></td><td>Close the window, which quits the Viewer</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>ProdDash Viewer</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>ProdDash Viewer</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>ProdDashViewer</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.waterschurch.proddash.viewer</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026 Waters Church</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.utilities</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSArchitecturePriority</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<!-- ProdDash is served over plain http on the local network, and the
|
||||||
|
connection sheet asks each address whether ProdDash is there. -->
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<!-- macOS shows this sentence verbatim when it asks. -->
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>ProdDash Viewer shows the dashboards of the ProdDash installations you add in Settings, which run on this network.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Draws AppIcon.png, the Viewer's icon: ProdDash's 2×2 tile mark on a
|
||||||
|
// display, so the Viewer and ProdDash itself tell apart in Finder and the
|
||||||
|
// Dock. Run it again only to change the icon; build.sh uses the PNG.
|
||||||
|
//
|
||||||
|
// swift Resources/make-icon.swift Resources/AppIcon.png
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
let side = 1024
|
||||||
|
let output = CommandLine.arguments.dropFirst().first ?? "AppIcon.png"
|
||||||
|
|
||||||
|
guard let bitmap = NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0, bitsPerPixel: 0),
|
||||||
|
let context = NSGraphicsContext(bitmapImageRep: bitmap) else { fatalError("no bitmap") }
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
let g = context.cgContext
|
||||||
|
// Top-left origin, as the measurements below are written.
|
||||||
|
g.translateBy(x: 0, y: CGFloat(side))
|
||||||
|
g.scaleBy(x: 1, y: -1)
|
||||||
|
|
||||||
|
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
|
||||||
|
CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255, green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||||
|
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
|
||||||
|
}
|
||||||
|
func rounded(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
|
||||||
|
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
||||||
|
}
|
||||||
|
func fill(_ path: CGPath, _ fillColor: CGColor) {
|
||||||
|
g.addPath(path); g.setFillColor(fillColor); g.fillPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body: Apple's icon grid — an 824-point rounded square, centred, on the
|
||||||
|
// soft shadow macOS icons sit on. ProdDash's own near-black.
|
||||||
|
let body = CGRect(x: 100, y: 100, width: 824, height: 824)
|
||||||
|
let bodyPath = rounded(body, 185)
|
||||||
|
g.saveGState()
|
||||||
|
g.setShadow(offset: CGSize(width: 0, height: 12), blur: 28, color: color(0x000000, 0.35))
|
||||||
|
fill(bodyPath, color(0x0b0e12))
|
||||||
|
g.restoreGState()
|
||||||
|
g.saveGState()
|
||||||
|
g.addPath(bodyPath); g.clip()
|
||||||
|
let shade = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB),
|
||||||
|
colors: [color(0x1b222b), color(0x0a0d11)] as CFArray, locations: [0, 1])!
|
||||||
|
g.drawLinearGradient(shade, start: CGPoint(x: 0, y: body.minY), end: CGPoint(x: 0, y: body.maxY), options: [])
|
||||||
|
g.restoreGState()
|
||||||
|
g.addPath(rounded(body.insetBy(dx: 1.5, dy: 1.5), 183.5))
|
||||||
|
g.setStrokeColor(color(0xffffff, 0.08)); g.setLineWidth(3); g.strokePath()
|
||||||
|
|
||||||
|
// The display: bezel, screen, neck and foot.
|
||||||
|
let bezel = CGRect(x: 212, y: 250, width: 600, height: 410)
|
||||||
|
let metal = color(0x3a434f)
|
||||||
|
fill(rounded(CGRect(x: 474, y: 650, width: 76, height: 84), 8), metal)
|
||||||
|
fill(rounded(CGRect(x: 372, y: 724, width: 280, height: 36), 18), color(0x4a5462))
|
||||||
|
fill(rounded(bezel, 44), metal)
|
||||||
|
let screen = bezel.insetBy(dx: 22, dy: 22)
|
||||||
|
fill(rounded(screen, 26), color(0x0c1015))
|
||||||
|
|
||||||
|
// ProdDash's mark on the screen: three outlined tiles and the lit one.
|
||||||
|
let padding: CGFloat = 46, gap: CGFloat = 30
|
||||||
|
let tileW = (screen.width - padding * 2 - gap) / 2
|
||||||
|
let tileH = (screen.height - padding * 2 - gap) / 2
|
||||||
|
for (column, row) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||||
|
let tile = CGRect(x: screen.minX + padding + CGFloat(column) * (tileW + gap),
|
||||||
|
y: screen.minY + padding + CGFloat(row) * (tileH + gap),
|
||||||
|
width: tileW, height: tileH)
|
||||||
|
if column == 1 && row == 1 {
|
||||||
|
fill(rounded(tile, 22), color(0x2fe39a))
|
||||||
|
} else {
|
||||||
|
let inset = tile.insetBy(dx: 5, dy: 5)
|
||||||
|
fill(rounded(inset, 18), color(0x121820))
|
||||||
|
g.addPath(rounded(inset, 18)); g.setStrokeColor(color(0x3d4652)); g.setLineWidth(10); g.strokePath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let png = bitmap.representation(using: .png, properties: [:]) else { fatalError("no png") }
|
||||||
|
try png.write(to: URL(fileURLWithPath: output))
|
||||||
|
print("wrote \(output)")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
ProdDash Viewer: one window showing a ProdDash dashboard, a tab per
|
||||||
|
installation when there's more than one, and Settings to say which.
|
||||||
|
|
||||||
|
The menus are deliberately short — Back, Forward, Reload Page and Settings,
|
||||||
|
plus what every Mac app has: the app menu, Edit (without which Copy and
|
||||||
|
Paste do nothing in the dashboard's text fields), Window and Help.
|
||||||
|
*/
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
|
static var version: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var viewer: ViewerWindowController!
|
||||||
|
private lazy var settingsWindow = SettingsWindowController()
|
||||||
|
private lazy var helpWindow = HelpWindowController()
|
||||||
|
|
||||||
|
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||||
|
NSWindow.allowsAutomaticWindowTabbing = false
|
||||||
|
NSApp.mainMenu = makeMainMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
viewer = ViewerWindowController()
|
||||||
|
viewer.onAddConnection = { [weak self] in self?.settingsWindow.show(addingConnection: true) }
|
||||||
|
viewer.showWindow(nil)
|
||||||
|
if ViewerSettings.shared.reopenInFullScreen {
|
||||||
|
DispatchQueue.main.async { self.viewer.window?.toggleFullScreen(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||||
|
if !flag { viewer.showWindow(nil) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
viewer?.rememberFullScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
|
||||||
|
|
||||||
|
@objc func showSettings(_ sender: Any?) { settingsWindow.show() }
|
||||||
|
@objc func showViewerHelp(_ sender: Any?) { helpWindow.show() }
|
||||||
|
|
||||||
|
// MARK: - Menus
|
||||||
|
|
||||||
|
private func makeMainMenu() -> NSMenu {
|
||||||
|
let name = "ProdDash Viewer"
|
||||||
|
let main = NSMenu()
|
||||||
|
|
||||||
|
let app = submenu(of: main, title: name)
|
||||||
|
app.addItem(withTitle: "About \(name)",
|
||||||
|
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
|
||||||
|
app.addItem(.separator())
|
||||||
|
app.addItem(withTitle: "Settings…", action: #selector(showSettings(_:)), keyEquivalent: ",")
|
||||||
|
app.addItem(.separator())
|
||||||
|
let services = submenu(of: app, title: "Services")
|
||||||
|
NSApp.servicesMenu = services
|
||||||
|
app.addItem(.separator())
|
||||||
|
app.addItem(withTitle: "Hide \(name)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||||
|
app.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)),
|
||||||
|
keyEquivalent: "h").keyEquivalentModifierMask = [.command, .option]
|
||||||
|
app.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
|
||||||
|
app.addItem(.separator())
|
||||||
|
app.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||||
|
|
||||||
|
let edit = submenu(of: main, title: "Edit")
|
||||||
|
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
|
||||||
|
edit.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
|
||||||
|
edit.addItem(.separator())
|
||||||
|
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||||
|
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||||
|
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||||
|
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||||
|
|
||||||
|
let view = submenu(of: main, title: "View")
|
||||||
|
view.addItem(withTitle: "Back", action: #selector(ViewerWindowController.goBackInPage(_:)), keyEquivalent: "[")
|
||||||
|
view.addItem(withTitle: "Forward", action: #selector(ViewerWindowController.goForwardInPage(_:)), keyEquivalent: "]")
|
||||||
|
view.addItem(withTitle: "Reload Page", action: #selector(ViewerWindowController.reloadPage(_:)), keyEquivalent: "r")
|
||||||
|
view.addItem(.separator())
|
||||||
|
// AppKit retitles this "Exit Full Screen" while the window is in full screen.
|
||||||
|
view.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)),
|
||||||
|
keyEquivalent: "f").keyEquivalentModifierMask = [.command, .control]
|
||||||
|
|
||||||
|
let window = submenu(of: main, title: "Window")
|
||||||
|
window.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||||
|
window.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
|
||||||
|
window.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
|
||||||
|
window.addItem(.separator())
|
||||||
|
window.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
|
||||||
|
NSApp.windowsMenu = window
|
||||||
|
|
||||||
|
let help = submenu(of: main, title: "Help")
|
||||||
|
help.addItem(withTitle: "\(name) Help", action: #selector(showViewerHelp(_:)), keyEquivalent: "?")
|
||||||
|
NSApp.helpMenu = help
|
||||||
|
|
||||||
|
return main
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func submenu(of parent: NSMenu, title: String) -> NSMenu {
|
||||||
|
let menu = NSMenu(title: title)
|
||||||
|
parent.addItem(withTitle: title, action: nil, keyEquivalent: "").submenu = menu
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// One ProdDash installation the viewer shows: the name its tab carries and
|
||||||
|
/// the address it answers at.
|
||||||
|
struct Connection: Codable, Identifiable, Equatable {
|
||||||
|
var id = UUID()
|
||||||
|
var name: String
|
||||||
|
/// Always a full http(s) URL — see `Address.normalize`.
|
||||||
|
var address: String
|
||||||
|
|
||||||
|
var url: URL? { URL(string: address) }
|
||||||
|
|
||||||
|
/// The name, or the host when nobody gave it one.
|
||||||
|
var displayName: String {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
return trimmed.isEmpty ? (url?.host ?? address) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayAddress: String { url.map(Address.display) ?? address }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Address {
|
||||||
|
/// ProdDash's port unless someone changed it (config/proddash.json).
|
||||||
|
static let defaultPort = 24500
|
||||||
|
|
||||||
|
/// What someone typed, as the URL ProdDash answers at: `10.3.11.152` and
|
||||||
|
/// `10.3.11.152:24500` both become `http://10.3.11.152:24500/`. A typed
|
||||||
|
/// scheme is taken at its word — `http://host` means port 80. A path is
|
||||||
|
/// kept, so an address can open a particular page.
|
||||||
|
static func normalize(_ text: String) -> URL? {
|
||||||
|
var typed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !typed.isEmpty else { return nil }
|
||||||
|
let hasScheme = typed.contains("://")
|
||||||
|
if !hasScheme { typed = "http://" + typed }
|
||||||
|
guard var parts = URLComponents(string: typed),
|
||||||
|
let scheme = parts.scheme?.lowercased(), scheme == "http" || scheme == "https",
|
||||||
|
let host = parts.host, !host.isEmpty else { return nil }
|
||||||
|
parts.scheme = scheme
|
||||||
|
if !hasScheme && parts.port == nil { parts.port = defaultPort }
|
||||||
|
if parts.path.isEmpty { parts.path = "/" }
|
||||||
|
return parts.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scheme, host and port — what decides whether a link stays in the viewer.
|
||||||
|
static func origin(_ url: URL) -> String? {
|
||||||
|
guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil }
|
||||||
|
let port = url.port ?? (scheme == "https" ? 443 : 80)
|
||||||
|
return "\(scheme)://\(host):\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For people to read: no trailing slash on a bare address.
|
||||||
|
static func display(_ url: URL) -> String {
|
||||||
|
var text = url.absoluteString
|
||||||
|
if url.path == "/" && url.query == nil && url.fragment == nil && text.hasSuffix("/") { text.removeLast() }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just the scheme, host and port, for people: `https://host`.
|
||||||
|
static func originDisplay(_ url: URL) -> String {
|
||||||
|
var parts = URLComponents()
|
||||||
|
parts.scheme = url.scheme
|
||||||
|
parts.host = url.host
|
||||||
|
parts.port = url.port
|
||||||
|
return parts.string ?? url.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host:port`, the way the error messages name a place.
|
||||||
|
static func place(_ url: URL?) -> String {
|
||||||
|
guard let url, let host = url.host else { return "that address" }
|
||||||
|
return url.port.map { "\(host):\($0)" } ?? host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connections, in tab order, kept in the viewer's preferences.
|
||||||
|
final class ConnectionStore: ObservableObject {
|
||||||
|
|
||||||
|
static let shared = ConnectionStore()
|
||||||
|
private static let key = "connections"
|
||||||
|
|
||||||
|
@Published private(set) var connections: [Connection] {
|
||||||
|
didSet { save() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let saved = UserDefaults.standard.data(forKey: Self.key)
|
||||||
|
connections = saved.flatMap { try? JSONDecoder().decode([Connection].self, from: $0) } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ connection: Connection) { connections.append(connection) }
|
||||||
|
|
||||||
|
func update(_ connection: Connection) {
|
||||||
|
guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return }
|
||||||
|
connections[index] = connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ id: UUID) { connections.removeAll { $0.id == id } }
|
||||||
|
|
||||||
|
func move(from source: IndexSet, to destination: Int) {
|
||||||
|
connections.move(fromOffsets: source, toOffset: destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard let data = try? JSONEncoder().encode(connections) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: Self.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Network failures in words an operator can act on.
|
||||||
|
enum NetworkProblem {
|
||||||
|
static func describe(_ error: Error, url: URL?) -> String {
|
||||||
|
let ns = error as NSError
|
||||||
|
let place = Address.place(url)
|
||||||
|
guard ns.domain == NSURLErrorDomain else { return ns.localizedDescription }
|
||||||
|
switch ns.code {
|
||||||
|
case NSURLErrorCannotConnectToHost:
|
||||||
|
return "Nothing is answering at \(place). ProdDash may not be running there, or it may use another port."
|
||||||
|
case NSURLErrorTimedOut:
|
||||||
|
return "\(place) didn't answer in time."
|
||||||
|
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
|
||||||
|
return "\(url?.host ?? place) can't be found on the network."
|
||||||
|
case NSURLErrorNotConnectedToInternet:
|
||||||
|
return "This Mac isn't on a network — or macOS is keeping ProdDash Viewer off it. Check System Settings → Privacy & Security → Local Network."
|
||||||
|
case NSURLErrorNetworkConnectionLost:
|
||||||
|
return "The connection to \(place) dropped."
|
||||||
|
case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted,
|
||||||
|
NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateHasBadDate,
|
||||||
|
NSURLErrorServerCertificateNotYetValid:
|
||||||
|
return "\(place) offered a secure connection this Mac doesn't trust."
|
||||||
|
default:
|
||||||
|
return ns.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks an address whether ProdDash is there, for the connection sheet.
|
||||||
|
enum Probe {
|
||||||
|
|
||||||
|
enum Answer: Equatable {
|
||||||
|
case prodDash
|
||||||
|
case somethingElse
|
||||||
|
case noAnswer(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let session: URLSession = {
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.timeoutIntervalForRequest = 4
|
||||||
|
config.timeoutIntervalForResource = 6
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// `/api/modules` is the first thing every dashboard asks for, and it
|
||||||
|
/// answers without a PIN — so `{"modules": …}` there means ProdDash.
|
||||||
|
static func ask(_ url: URL) async -> Answer {
|
||||||
|
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return .somethingElse }
|
||||||
|
parts.path = "/api/modules"
|
||||||
|
parts.query = nil
|
||||||
|
parts.fragment = nil
|
||||||
|
guard let target = parts.url else { return .somethingElse }
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(from: target)
|
||||||
|
let ok = (response as? HTTPURLResponse)?.statusCode == 200
|
||||||
|
let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
return ok && body?["modules"] != nil ? .prodDash : .somethingElse
|
||||||
|
} catch {
|
||||||
|
return .noAnswer(NetworkProblem.describe(error, url: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
One installation's dashboard: its web view, and what shows instead while the
|
||||||
|
installation can't be reached. Every page stays alive while the viewer runs,
|
||||||
|
so switching tabs is instant and the dashboards behind the one on screen stay
|
||||||
|
live.
|
||||||
|
*/
|
||||||
|
final class DashboardPage: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||||
|
|
||||||
|
final class Status: ObservableObject {
|
||||||
|
enum Phase: Equatable {
|
||||||
|
case connecting
|
||||||
|
case showing
|
||||||
|
case unreachable(String)
|
||||||
|
}
|
||||||
|
@Published var phase: Phase = .connecting
|
||||||
|
@Published var name = ""
|
||||||
|
@Published var address = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var connection: Connection
|
||||||
|
/// What the window shows: the web view, with the status view over it.
|
||||||
|
let view = NSView()
|
||||||
|
private(set) var webView: WKWebView
|
||||||
|
let status = Status()
|
||||||
|
/// Something the tab bar or the menus show has changed.
|
||||||
|
var onChange: () -> Void = {}
|
||||||
|
|
||||||
|
private let window: () -> NSWindow?
|
||||||
|
private var statusView: NSView!
|
||||||
|
private var retryTimer: Timer?
|
||||||
|
private var retryDelay: TimeInterval = 2
|
||||||
|
private var revealTimer: Timer?
|
||||||
|
private var lastAttempt: URL?
|
||||||
|
|
||||||
|
var isUnreachable: Bool {
|
||||||
|
if case .unreachable = status.phase { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
init(connection: Connection, window: @escaping () -> NSWindow?) {
|
||||||
|
self.connection = connection
|
||||||
|
self.window = window
|
||||||
|
self.webView = Self.makeWebView()
|
||||||
|
super.init()
|
||||||
|
status.name = connection.displayName
|
||||||
|
status.address = connection.displayAddress
|
||||||
|
install(webView)
|
||||||
|
let overlay = NSHostingView(rootView: PageStatusView(status: status) { [weak self] in self?.retry() })
|
||||||
|
overlay.sizingOptions = [] // sized by the window, never the other way round
|
||||||
|
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(overlay)
|
||||||
|
pin(overlay)
|
||||||
|
statusView = overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeWebView() -> WKWebView {
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = .default()
|
||||||
|
// The dashboard's own full-screen button uses the Fullscreen API.
|
||||||
|
config.preferences.isElementFullscreenEnabled = true
|
||||||
|
// An alert may need to sound on a dashboard nobody has touched.
|
||||||
|
config.mediaTypesRequiringUserActionForPlayback = []
|
||||||
|
config.applicationNameForUserAgent = "ProdDashViewer/\(AppDelegate.version)"
|
||||||
|
let web = WKWebView(frame: .zero, configuration: config)
|
||||||
|
web.allowsBackForwardNavigationGestures = true
|
||||||
|
// Safari's Develop menu can inspect a dashboard running in here.
|
||||||
|
if #available(macOS 13.3, *) { web.isInspectable = true }
|
||||||
|
return web
|
||||||
|
}
|
||||||
|
|
||||||
|
private func install(_ web: WKWebView) {
|
||||||
|
web.navigationDelegate = self
|
||||||
|
web.uiDelegate = self
|
||||||
|
// Hidden until it has drawn something: no white flash on a dark booth
|
||||||
|
// display while the first load is in flight.
|
||||||
|
web.isHidden = true
|
||||||
|
web.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
if let statusView {
|
||||||
|
view.addSubview(web, positioned: .below, relativeTo: statusView)
|
||||||
|
} else {
|
||||||
|
view.addSubview(web)
|
||||||
|
}
|
||||||
|
pin(web)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pin(_ subview: NSView) {
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
subview.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
func load() { load(connection.url) }
|
||||||
|
|
||||||
|
private func load(_ url: URL?) {
|
||||||
|
guard let url else {
|
||||||
|
return show(.unreachable("“\(connection.address)” isn't an address ProdDash Viewer can open. Change it in Settings."))
|
||||||
|
}
|
||||||
|
lastAttempt = url
|
||||||
|
webView.load(URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 15))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload, or — while the installation can't be reached — try again now.
|
||||||
|
func reload() {
|
||||||
|
if isUnreachable || webView.url == nil { return retry() }
|
||||||
|
webView.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func goBack() { webView.goBack() }
|
||||||
|
func goForward() { webView.goForward() }
|
||||||
|
|
||||||
|
func retry() {
|
||||||
|
retryTimer?.invalidate()
|
||||||
|
load(lastAttempt ?? connection.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An installation that isn't answering is usually one restarting — an
|
||||||
|
/// update, a reboot — so keep asking, less often the longer it takes.
|
||||||
|
private func scheduleRetry() {
|
||||||
|
retryTimer?.invalidate()
|
||||||
|
retryTimer = Timer.scheduledTimer(withTimeInterval: retryDelay, repeats: false) { [weak self] _ in
|
||||||
|
self?.retry()
|
||||||
|
}
|
||||||
|
retryDelay = min(retryDelay * 2, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings changed this connection. A new address is a different
|
||||||
|
/// installation, so it gets a fresh web view — Back can't wander into the
|
||||||
|
/// old one.
|
||||||
|
func update(_ new: Connection) {
|
||||||
|
let moved = new.address != connection.address
|
||||||
|
connection = new
|
||||||
|
status.name = new.displayName
|
||||||
|
status.address = new.displayAddress
|
||||||
|
guard moved else { return onChange() }
|
||||||
|
retire(webView)
|
||||||
|
webView = Self.makeWebView()
|
||||||
|
install(webView)
|
||||||
|
retryTimer?.invalidate()
|
||||||
|
revealTimer?.invalidate()
|
||||||
|
retryDelay = 2
|
||||||
|
show(.connecting)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connection was removed.
|
||||||
|
func close() {
|
||||||
|
retryTimer?.invalidate()
|
||||||
|
revealTimer?.invalidate()
|
||||||
|
retire(webView)
|
||||||
|
view.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retire(_ web: WKWebView) {
|
||||||
|
web.stopLoading()
|
||||||
|
web.navigationDelegate = nil
|
||||||
|
web.uiDelegate = nil
|
||||||
|
web.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func show(_ phase: Status.Phase) {
|
||||||
|
status.phase = phase
|
||||||
|
statusView.isHidden = phase == .showing
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isHome(_ url: URL) -> Bool {
|
||||||
|
guard let home = connection.url.flatMap(Address.origin) else { return false }
|
||||||
|
return Address.origin(url) == home
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
guard webView === self.webView else { return }
|
||||||
|
reveal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normally the load finishes within moments of this; but a tile's frame
|
||||||
|
/// that never answers holds the load event back, and the dashboard
|
||||||
|
/// shouldn't sit behind "Connecting…" waiting for it.
|
||||||
|
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||||
|
guard webView === self.webView, status.phase != .showing else { return }
|
||||||
|
revealTimer?.invalidate()
|
||||||
|
revealTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self, weak webView] _ in
|
||||||
|
guard let self, let webView, webView === self.webView else { return }
|
||||||
|
self.reveal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reveal() {
|
||||||
|
revealTimer?.invalidate()
|
||||||
|
retryTimer?.invalidate()
|
||||||
|
retryDelay = 2
|
||||||
|
webView.isHidden = false
|
||||||
|
if status.phase != .showing { show(.showing) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
failed(webView, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
failed(webView, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failed(_ web: WKWebView, _ error: Error) {
|
||||||
|
guard web === webView else { return }
|
||||||
|
let ns = error as NSError
|
||||||
|
// Not failures: a load replaced by another, or one handed to the browser.
|
||||||
|
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { return }
|
||||||
|
if ns.domain == "WebKitErrorDomain" && (ns.code == 102 || ns.code == 204) { return }
|
||||||
|
if let failing = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL, isHome(failing) {
|
||||||
|
lastAttempt = failing
|
||||||
|
}
|
||||||
|
show(.unreachable(NetworkProblem.describe(error, url: lastAttempt)))
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page's process died (memory pressure, a WebKit crash). A booth
|
||||||
|
/// display is never left blank: bring it back — after the usual retry
|
||||||
|
/// delay, so a page that takes its process down as it loads can't spin.
|
||||||
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
|
guard webView === self.webView else { return }
|
||||||
|
if let url = webView.url, isHome(url) { lastAttempt = url }
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The viewer shows ProdDash and nothing else: the installation's own
|
||||||
|
/// pages open here, any other site opens in the browser.
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
guard let url = action.request.url else { return decisionHandler(.allow) }
|
||||||
|
// A tile may embed another site in a frame; only the page itself is policed.
|
||||||
|
guard action.targetFrame?.isMainFrame ?? true else { return decisionHandler(.allow) }
|
||||||
|
switch url.scheme?.lowercased() {
|
||||||
|
case "http", "https":
|
||||||
|
if !isHome(url) {
|
||||||
|
let followed = action.targetFrame == nil || status.phase == .showing
|
||||||
|
|| action.navigationType == .linkActivated || action.navigationType == .formSubmitted
|
||||||
|
if followed {
|
||||||
|
NSWorkspace.shared.open(url) // someone followed a link off ProdDash
|
||||||
|
} else {
|
||||||
|
redirected(to: url)
|
||||||
|
}
|
||||||
|
return decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
if action.targetFrame == nil {
|
||||||
|
// target="_blank" or window.open on ProdDash's own page: one
|
||||||
|
// window, so it opens here.
|
||||||
|
let request = action.request
|
||||||
|
DispatchQueue.main.async { webView.load(request) }
|
||||||
|
return decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
decisionHandler(.allow)
|
||||||
|
case "about", "blob", "data":
|
||||||
|
decisionHandler(.allow)
|
||||||
|
case "file":
|
||||||
|
// A file dropped somewhere the page didn't take it.
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
default:
|
||||||
|
NSWorkspace.shared.open(url) // mailto: and the like
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something the page can't show is a download: the browser does those.
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
|
||||||
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||||
|
if response.isForMainFrame, !response.canShowMIMEType, let url = response.response.url {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
return decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address itself sends the viewer somewhere else — a proxy, http
|
||||||
|
/// moving to https. Handing that to the browser would leave the page
|
||||||
|
/// stuck connecting, and do it again on every retry.
|
||||||
|
private func redirected(to url: URL) {
|
||||||
|
show(.unreachable("\(Address.place(connection.url)) sends ProdDash Viewer on to \(Address.originDisplay(url)). If that's where ProdDash answers, change the address in Settings to it."))
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// window.open(), which doesn't pass through decidePolicyFor: ProdDash's
|
||||||
|
/// own pages open here, other sites in the browser. The viewer never
|
||||||
|
/// opens a second window.
|
||||||
|
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||||
|
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||||
|
guard let url = action.request.url else { return nil }
|
||||||
|
if isHome(url) {
|
||||||
|
webView.load(action.request)
|
||||||
|
} else if url.scheme == "http" || url.scheme == "https" {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JavaScript dialogs and file pickers
|
||||||
|
|
||||||
|
// A WKWebView answers alert, confirm and prompt with "no" unless the app
|
||||||
|
// shows them — and the admin page confirms before deleting a layout, Band
|
||||||
|
// Lineup prompts for names.
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||||
|
let alert = dialog(message)
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
present(alert) { _ in completionHandler() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
let alert = dialog(message)
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
present(alert) { completionHandler($0 == .alertFirstButtonReturn) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String,
|
||||||
|
defaultText: String?, initiatedByFrame frame: WKFrameInfo,
|
||||||
|
completionHandler: @escaping (String?) -> Void) {
|
||||||
|
let alert = dialog(prompt)
|
||||||
|
let field = NSTextField(string: defaultText ?? "")
|
||||||
|
field.frame = NSRect(x: 0, y: 0, width: 280, height: 22)
|
||||||
|
alert.accessoryView = field
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
alert.window.initialFirstResponder = field
|
||||||
|
present(alert) { completionHandler($0 == .alertFirstButtonReturn ? field.stringValue : nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.canChooseDirectories = parameters.allowsDirectories
|
||||||
|
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||||
|
if let window = window(), window.attachedSheet == nil {
|
||||||
|
panel.beginSheetModal(for: window) { completionHandler($0 == .OK ? panel.urls : nil) }
|
||||||
|
} else {
|
||||||
|
completionHandler(panel.runModal() == .OK ? panel.urls : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Titled with the installation's name, so it's clear which one is asking.
|
||||||
|
private func dialog(_ message: String) -> NSAlert {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = connection.displayName
|
||||||
|
alert.informativeText = message
|
||||||
|
return alert
|
||||||
|
}
|
||||||
|
|
||||||
|
private func present(_ alert: NSAlert, then done: @escaping (NSApplication.ModalResponse) -> Void) {
|
||||||
|
if let window = window(), window.attachedSheet == nil {
|
||||||
|
alert.beginSheetModal(for: window, completionHandler: done)
|
||||||
|
} else {
|
||||||
|
done(alert.runModal())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Over the web view while there is nothing to show yet, or while the
|
||||||
|
/// installation can't be reached.
|
||||||
|
struct PageStatusView: View {
|
||||||
|
|
||||||
|
@ObservedObject var status: DashboardPage.Status
|
||||||
|
let retry: () -> Void
|
||||||
|
/// A fast load shouldn't flash a spinner.
|
||||||
|
@State private var patient = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(nsColor: .windowBackgroundColor)
|
||||||
|
switch status.phase {
|
||||||
|
case .showing:
|
||||||
|
EmptyView()
|
||||||
|
case .connecting:
|
||||||
|
if patient {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Connecting to \(status.name)…").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .unreachable(let why):
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "network.slash")
|
||||||
|
.font(.system(size: 44, weight: .light))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
Text("Can’t Reach \(status.name)")
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
Text(why)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text(status.address)
|
||||||
|
.font(.callout.monospaced())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Button("Try Again", action: retry)
|
||||||
|
.controlSize(.large)
|
||||||
|
.padding(.top, 8)
|
||||||
|
Text("ProdDash Viewer keeps trying by itself.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(800))
|
||||||
|
patient = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import AppKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// Help → ProdDash Viewer Help: the page that ships inside the app, so it
|
||||||
|
/// opens with no network and always matches this version.
|
||||||
|
final class HelpWindowController: NSWindowController, WKNavigationDelegate {
|
||||||
|
|
||||||
|
convenience init() {
|
||||||
|
let web = WKWebView()
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 720),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
|
backing: .buffered, defer: false)
|
||||||
|
window.title = "ProdDash Viewer Help"
|
||||||
|
window.minSize = NSSize(width: 420, height: 360)
|
||||||
|
window.contentView = web
|
||||||
|
window.tabbingMode = .disallowed
|
||||||
|
window.isRestorable = false
|
||||||
|
window.center()
|
||||||
|
window.setFrameAutosaveName("ProdDashViewerHelp")
|
||||||
|
self.init(window: window)
|
||||||
|
web.navigationDelegate = self
|
||||||
|
if let page = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "Help") {
|
||||||
|
web.loadFileURL(page, allowingReadAccessTo: page.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
showWindow(nil)
|
||||||
|
window?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The help page itself stays here; its links to the web open in the browser.
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
guard let url = action.request.url, !url.isFileURL, url.scheme != "about" else {
|
||||||
|
return decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Settings (⌘,): a standard toolbar-tabbed settings window with two panes,
|
||||||
|
/// Connections and General. Every change applies as it's made.
|
||||||
|
final class SettingsWindowController: NSWindowController {
|
||||||
|
|
||||||
|
private var model: SettingsModel!
|
||||||
|
private var tabs: NSTabViewController!
|
||||||
|
|
||||||
|
convenience init() {
|
||||||
|
let model = SettingsModel()
|
||||||
|
let tabs = NSTabViewController()
|
||||||
|
tabs.tabStyle = .toolbar
|
||||||
|
tabs.addTabViewItem(Self.pane("Connections", symbol: "network",
|
||||||
|
ConnectionsPane(store: .shared, model: model)))
|
||||||
|
tabs.addTabViewItem(Self.pane("General", symbol: "gearshape",
|
||||||
|
GeneralPane(settings: .shared)))
|
||||||
|
let window = NSWindow(contentViewController: tabs)
|
||||||
|
window.styleMask = [.titled, .closable]
|
||||||
|
window.toolbarStyle = .preference
|
||||||
|
window.isRestorable = false
|
||||||
|
self.init(window: window)
|
||||||
|
self.model = model
|
||||||
|
self.tabs = tabs
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func pane<Content: View>(_ label: String, symbol: String, _ content: Content) -> NSTabViewItem {
|
||||||
|
let controller = NSHostingController(rootView: content)
|
||||||
|
controller.sizingOptions = .preferredContentSize
|
||||||
|
controller.title = label // the window takes its title from the pane
|
||||||
|
let item = NSTabViewItem(viewController: controller)
|
||||||
|
item.label = label
|
||||||
|
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show Settings; `addingConnection` opens it on the add-a-connection sheet.
|
||||||
|
func show(addingConnection: Bool = false) {
|
||||||
|
if addingConnection {
|
||||||
|
tabs.selectedTabViewItemIndex = 0
|
||||||
|
model.pendingAdd = true
|
||||||
|
}
|
||||||
|
showWindow(nil)
|
||||||
|
window?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SettingsModel: ObservableObject {
|
||||||
|
/// Set to open the add sheet as soon as the Connections pane can.
|
||||||
|
@Published var pendingAdd = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connections
|
||||||
|
|
||||||
|
private struct ConnectionDraft: Identifiable {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var address: String
|
||||||
|
var isNew: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConnectionsPane: View {
|
||||||
|
|
||||||
|
@ObservedObject var store: ConnectionStore
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
@State private var selection: UUID?
|
||||||
|
@State private var editing: ConnectionDraft?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Each connection is a ProdDash installation on your network. With more than one, a tab bar at the top of the window switches between them — drag them into the order you want.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
List(selection: $selection) {
|
||||||
|
ForEach(store.connections) { connection in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(connection.displayName)
|
||||||
|
Text(connection.displayAddress)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.tag(connection.id)
|
||||||
|
}
|
||||||
|
.onMove { store.move(from: $0, to: $1) }
|
||||||
|
}
|
||||||
|
.listStyle(.bordered(alternatesRowBackgrounds: true))
|
||||||
|
.contextMenu(forSelectionType: UUID.self) { ids in
|
||||||
|
Button("Edit…") { edit(ids.first) }
|
||||||
|
.disabled(ids.count != 1)
|
||||||
|
Button("Remove") {
|
||||||
|
ids.forEach(store.remove)
|
||||||
|
if let selected = selection, ids.contains(selected) { selection = nil }
|
||||||
|
}
|
||||||
|
} primaryAction: { ids in
|
||||||
|
edit(ids.first)
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if store.connections.isEmpty {
|
||||||
|
Text("No Connections").foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 210)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Button(action: add) {
|
||||||
|
Image(systemName: "plus").frame(width: 14)
|
||||||
|
}
|
||||||
|
.help("Add a connection")
|
||||||
|
.accessibilityLabel("Add")
|
||||||
|
Button(action: removeSelected) {
|
||||||
|
Image(systemName: "minus").frame(width: 14)
|
||||||
|
}
|
||||||
|
.help("Remove the selected connection")
|
||||||
|
.accessibilityLabel("Remove")
|
||||||
|
.disabled(selection == nil)
|
||||||
|
Spacer()
|
||||||
|
Button("Edit…") { edit(selection) }
|
||||||
|
.disabled(selection == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 520)
|
||||||
|
.sheet(item: $editing) { draft in
|
||||||
|
ConnectionEditor(draft: draft, onSave: save)
|
||||||
|
}
|
||||||
|
.onReceive(model.$pendingAdd) { pending in
|
||||||
|
guard pending else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
model.pendingAdd = false
|
||||||
|
add()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func add() {
|
||||||
|
editing = ConnectionDraft(id: UUID(), name: "", address: "", isNew: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func edit(_ id: UUID?) {
|
||||||
|
guard let id, let connection = store.connections.first(where: { $0.id == id }) else { return }
|
||||||
|
editing = ConnectionDraft(id: id, name: connection.name, address: connection.displayAddress, isNew: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSelected() {
|
||||||
|
guard let id = selection else { return }
|
||||||
|
store.remove(id)
|
||||||
|
selection = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save(_ draft: ConnectionDraft) {
|
||||||
|
guard let url = Address.normalize(draft.address) else { return }
|
||||||
|
let connection = Connection(id: draft.id,
|
||||||
|
name: draft.name.trimmingCharacters(in: .whitespaces),
|
||||||
|
address: url.absoluteString)
|
||||||
|
if draft.isNew { store.add(connection) } else { store.update(connection) }
|
||||||
|
selection = connection.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The add / edit sheet. It asks the address whether ProdDash is there as
|
||||||
|
/// you type, so a mistyped address shows up here rather than on the booth
|
||||||
|
/// display — but saving never depends on it: the installation may simply be
|
||||||
|
/// switched off right now.
|
||||||
|
private struct ConnectionEditor: View {
|
||||||
|
|
||||||
|
@State var draft: ConnectionDraft
|
||||||
|
let onSave: (ConnectionDraft) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private enum Check: Equatable { case idle, checking, answer(Probe.Answer) }
|
||||||
|
@State private var check: Check = .idle
|
||||||
|
|
||||||
|
private var url: URL? { Address.normalize(draft.address) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text(draft.isNew ? "Add Connection" : "Edit Connection")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Form {
|
||||||
|
TextField("Name:", text: $draft.name, prompt: Text(url?.host ?? "AB Booth"))
|
||||||
|
TextField("Address:", text: $draft.address, prompt: Text("10.3.11.152:24500"))
|
||||||
|
LabeledContent {
|
||||||
|
status
|
||||||
|
.font(.caption)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading)
|
||||||
|
} label: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button(draft.isNew ? "Add" : "Save") {
|
||||||
|
onSave(draft)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(url == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 460)
|
||||||
|
.task(id: draft.address) {
|
||||||
|
guard let url else { return check = .idle }
|
||||||
|
check = .checking
|
||||||
|
try? await Task.sleep(for: .milliseconds(500)) // let the typing finish
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
let answer = await Probe.ask(url)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
check = .answer(answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var status: some View {
|
||||||
|
switch check {
|
||||||
|
case .idle where url == nil:
|
||||||
|
Text(draft.address.isEmpty
|
||||||
|
? "The address you open ProdDash at in a browser. Without a port, ProdDash’s \(String(Address.defaultPort)) is used."
|
||||||
|
: "That isn’t an address.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .idle, .checking: // idle with an address: the check is about to start
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ProgressView().controlSize(.mini)
|
||||||
|
Text("Checking \(url.map(Address.display) ?? "")…").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
case .answer(.prodDash):
|
||||||
|
Label("ProdDash is answering at \(url.map(Address.display) ?? "").", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
case .answer(.somethingElse):
|
||||||
|
Label("Something answers at \(url.map(Address.display) ?? "") — but it isn’t ProdDash.", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
case .answer(.noAnswer(let why)):
|
||||||
|
Label(why, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - General
|
||||||
|
|
||||||
|
private struct GeneralPane: View {
|
||||||
|
|
||||||
|
@ObservedObject var settings: ViewerSettings
|
||||||
|
@State private var openAtLogin = LoginItem.isEnabled || LoginItem.needsApproval
|
||||||
|
@State private var needsApproval = LoginItem.needsApproval
|
||||||
|
@State private var loginProblem: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("Open at login", isOn: Binding(
|
||||||
|
get: { openAtLogin },
|
||||||
|
set: { wanted in
|
||||||
|
loginProblem = LoginItem.set(wanted)
|
||||||
|
needsApproval = LoginItem.needsApproval
|
||||||
|
openAtLogin = LoginItem.isEnabled || needsApproval
|
||||||
|
}))
|
||||||
|
if needsApproval {
|
||||||
|
HStack {
|
||||||
|
Text("Waiting for your approval in System Settings.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("Open Login Items…") { LoginItem.openSystemSettings() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let loginProblem {
|
||||||
|
Text(loginProblem).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $settings.keepDisplayAwake) {
|
||||||
|
Text("Keep the display awake")
|
||||||
|
Text("While a dashboard is on screen, the display doesn’t dim or sleep.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.scrollDisabled(true)
|
||||||
|
.frame(width: 520, height: needsApproval || loginProblem != nil ? 200 : 152)
|
||||||
|
.onAppear(perform: refreshLoginItem)
|
||||||
|
// Approving it in System Settings happens elsewhere; catch up on return.
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in
|
||||||
|
refreshLoginItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshLoginItem() {
|
||||||
|
needsApproval = LoginItem.needsApproval
|
||||||
|
openAtLogin = LoginItem.isEnabled || needsApproval
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// What the tab bar shows: one tab per connection, in Settings' order.
|
||||||
|
final class TabBarModel: ObservableObject {
|
||||||
|
|
||||||
|
struct Tab: Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var title: String
|
||||||
|
var address: String
|
||||||
|
var unreachable: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var tabs: [Tab] = []
|
||||||
|
@Published var selection: UUID?
|
||||||
|
var onSelect: (UUID) -> Void = { _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hosts the tabs. A click on a tab works even while another app is in
|
||||||
|
/// front, rather than only bringing the viewer forward.
|
||||||
|
final class TabBarHostingView: NSHostingView<TabBar> {
|
||||||
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The slim bar of installation names. It sits in the window's title bar
|
||||||
|
/// area, so it takes the title bar's material in light and dark alike.
|
||||||
|
struct TabBar: View {
|
||||||
|
|
||||||
|
@ObservedObject var model: TabBarModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(model.tabs) { tab in
|
||||||
|
TabButton(tab: tab, selected: tab.id == model.selection) { model.onSelect(tab.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TabButton: View {
|
||||||
|
|
||||||
|
let tab: TabBarModel.Tab
|
||||||
|
let selected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
@State private var hovering = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if tab.unreachable {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.imageScale(.small)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
Text(tab.title)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? .primary : .secondary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
|
.fill(Color.primary.opacity(selected ? 0.12 : hovering ? 0.06 : 0))
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.focusable(false) // the keyboard belongs to the dashboard; ⌃Tab switches
|
||||||
|
.onHover { hovering = $0 }
|
||||||
|
.help(tab.unreachable ? "\(tab.address) — not answering" : tab.address)
|
||||||
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
|
/// The viewer's own preferences. The connections themselves are in
|
||||||
|
/// ConnectionStore.
|
||||||
|
final class ViewerSettings: ObservableObject {
|
||||||
|
|
||||||
|
static let shared = ViewerSettings()
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
/// Keep the display on while a dashboard is on screen — a booth display
|
||||||
|
/// that dims halfway through a service is the failure this is here for.
|
||||||
|
@Published var keepDisplayAwake: Bool {
|
||||||
|
didSet { defaults.set(keepDisplayAwake, forKey: "keepDisplayAwake") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
defaults.register(defaults: ["keepDisplayAwake": true])
|
||||||
|
keepDisplayAwake = defaults.bool(forKey: "keepDisplayAwake")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window was in full screen when the viewer last quit, so it opens in
|
||||||
|
/// full screen again — a booth Mac that restarts comes back as it was left.
|
||||||
|
var reopenInFullScreen: Bool {
|
||||||
|
get { defaults.bool(forKey: "reopenInFullScreen") }
|
||||||
|
set { defaults.set(newValue, forKey: "reopenInFullScreen") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tab that was showing, so the viewer reopens on it.
|
||||||
|
var selectedConnection: UUID? {
|
||||||
|
get { defaults.string(forKey: "selectedConnection").flatMap(UUID.init(uuidString:)) }
|
||||||
|
set { defaults.set(newValue?.uuidString, forKey: "selectedConnection") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Open at login", through the macOS 13 login-item API. The operator may
|
||||||
|
/// still have to approve it in System Settings — hence `needsApproval`, which
|
||||||
|
/// Settings shows rather than failing silently.
|
||||||
|
enum LoginItem {
|
||||||
|
|
||||||
|
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
|
||||||
|
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
|
||||||
|
|
||||||
|
/// Nil when it worked; otherwise what went wrong, for Settings to show.
|
||||||
|
static func set(_ enabled: Bool) -> String? {
|
||||||
|
do {
|
||||||
|
if enabled {
|
||||||
|
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
|
||||||
|
} else {
|
||||||
|
if SMAppService.mainApp.status != .notRegistered { try SMAppService.mainApp.unregister() }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func openSystemSettings() {
|
||||||
|
SMAppService.openSystemSettingsLoginItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/**
|
||||||
|
The viewer's one window: the selected installation's dashboard and — when
|
||||||
|
there is more than one — a slim tab bar of their names.
|
||||||
|
|
||||||
|
In a window the bar floats over the top of the dashboard, just under the
|
||||||
|
title bar: it comes out while the pointer is on the title bar and tucks away
|
||||||
|
after. It is deliberately not part of the title bar — anything there makes
|
||||||
|
WebKit push the whole page down to clear it, and a dashboard jumping every
|
||||||
|
time the bar came and went would be worse than no bar at all.
|
||||||
|
|
||||||
|
In full screen the bar *is* part of the title bar (a titlebar accessory), so
|
||||||
|
it comes down with the menu bar like any Mac app's toolbar; there the title
|
||||||
|
bar lives in a window of its own and the page stays put.
|
||||||
|
*/
|
||||||
|
final class ViewerWindowController: NSWindowController, NSWindowDelegate, NSMenuItemValidation {
|
||||||
|
|
||||||
|
static let tabBarHeight: CGFloat = 32
|
||||||
|
|
||||||
|
/// The standard title bar's height: where the dashboard starts.
|
||||||
|
private static let titlebarHeight: CGFloat = {
|
||||||
|
let probe = NSRect(x: 0, y: 0, width: 100, height: 100)
|
||||||
|
return NSWindow.frameRect(forContentRect: probe, styleMask: [.titled]).height - probe.height
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Empty state's "Add Connection…".
|
||||||
|
var onAddConnection: () -> Void = {}
|
||||||
|
|
||||||
|
private let store = ConnectionStore.shared
|
||||||
|
private let settings = ViewerSettings.shared
|
||||||
|
private var pages: [UUID: DashboardPage] = [:]
|
||||||
|
private var order: [UUID] = []
|
||||||
|
private var selection: UUID?
|
||||||
|
|
||||||
|
private let content = ViewerContentView()
|
||||||
|
private let pageArea = NSView()
|
||||||
|
private var pageAreaTop: NSLayoutConstraint!
|
||||||
|
private var emptyState: NSView!
|
||||||
|
|
||||||
|
private let tabModel = TabBarModel()
|
||||||
|
private var tabOverlay: TabBarOverlay!
|
||||||
|
private var tabOverlayTop: NSLayoutConstraint!
|
||||||
|
private let tabAccessory = NSTitlebarAccessoryViewController()
|
||||||
|
private var accessoryAttached = false
|
||||||
|
private var tabBarShown = false
|
||||||
|
private var hideTimer: Timer?
|
||||||
|
private var inFullScreen = false
|
||||||
|
private var leaving = false
|
||||||
|
|
||||||
|
private var awakeActivity: NSObjectProtocol?
|
||||||
|
private var keyMonitor: Any?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private var currentPage: DashboardPage? { selection.flatMap { pages[$0] } }
|
||||||
|
private var hasTabs: Bool { order.count > 1 }
|
||||||
|
|
||||||
|
convenience init() {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
|
backing: .buffered, defer: false)
|
||||||
|
window.title = "ProdDash Viewer"
|
||||||
|
window.minSize = NSSize(width: 640, height: 420)
|
||||||
|
window.tabbingMode = .disallowed // the tab bar here is ProdDash's, not AppKit's
|
||||||
|
window.collectionBehavior.insert(.fullScreenPrimary)
|
||||||
|
window.isRestorable = false // frame and full screen are kept below instead
|
||||||
|
window.center()
|
||||||
|
window.setFrameAutosaveName("ProdDashViewerWindow")
|
||||||
|
self.init(window: window)
|
||||||
|
window.delegate = self
|
||||||
|
build(in: window)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func build(in window: NSWindow) {
|
||||||
|
// The content runs under the title bar so the pointer can be watched
|
||||||
|
// there; the dashboard itself starts below it.
|
||||||
|
window.contentView = content
|
||||||
|
|
||||||
|
pageArea.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
content.addSubview(pageArea)
|
||||||
|
pageAreaTop = pageArea.topAnchor.constraint(equalTo: content.topAnchor, constant: Self.titlebarHeight)
|
||||||
|
|
||||||
|
let empty = NSHostingView(rootView: EmptyStateView { [weak self] in self?.onAddConnection() })
|
||||||
|
empty.sizingOptions = [] // sized by the window, never the other way round
|
||||||
|
empty.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
content.addSubview(empty)
|
||||||
|
emptyState = empty
|
||||||
|
|
||||||
|
tabOverlay = TabBarOverlay(model: tabModel)
|
||||||
|
tabOverlay.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
tabOverlay.isHidden = true
|
||||||
|
tabOverlay.alphaValue = 0
|
||||||
|
content.addSubview(tabOverlay)
|
||||||
|
tabOverlayTop = tabOverlay.topAnchor.constraint(equalTo: content.topAnchor, constant: hiddenOverlayTop)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
pageAreaTop,
|
||||||
|
pageArea.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||||
|
pageArea.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||||
|
pageArea.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
||||||
|
empty.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||||||
|
empty.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||||||
|
empty.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||||||
|
empty.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||||||
|
tabOverlayTop,
|
||||||
|
tabOverlay.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||||
|
tabOverlay.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||||
|
tabOverlay.heightAnchor.constraint(equalToConstant: Self.tabBarHeight),
|
||||||
|
])
|
||||||
|
|
||||||
|
let bar = TabBarHostingView(rootView: TabBar(model: tabModel))
|
||||||
|
bar.sizingOptions = []
|
||||||
|
bar.frame = NSRect(x: 0, y: 0, width: 800, height: Self.tabBarHeight)
|
||||||
|
tabAccessory.view = bar
|
||||||
|
tabAccessory.layoutAttribute = .bottom
|
||||||
|
tabAccessory.fullScreenMinHeight = 0 // out of sight until the menu bar comes down
|
||||||
|
tabModel.onSelect = { [weak self] id in self?.select(id) }
|
||||||
|
|
||||||
|
content.topZoneHeight = { [weak self] in self?.revealZoneHeight ?? 0 }
|
||||||
|
content.onPointerInTopZone = { [weak self] inside in self?.pointerInTopZone(inside) }
|
||||||
|
|
||||||
|
selection = settings.selectedConnection
|
||||||
|
// @Published sends before it stores, so use the value it sends.
|
||||||
|
store.$connections
|
||||||
|
.sink { [weak self] connections in self?.sync(connections) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
settings.$keepDisplayAwake
|
||||||
|
.sink { [weak self] keep in self?.updateDisplayAwake(keep) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
installKeyboardSwitching()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connections and tabs
|
||||||
|
|
||||||
|
private func sync(_ connections: [Connection]) {
|
||||||
|
let ids = Set(connections.map(\.id))
|
||||||
|
let hadTabs = hasTabs
|
||||||
|
for (id, page) in pages where !ids.contains(id) {
|
||||||
|
page.close()
|
||||||
|
pages[id] = nil
|
||||||
|
}
|
||||||
|
for connection in connections {
|
||||||
|
if let page = pages[connection.id] {
|
||||||
|
page.update(connection)
|
||||||
|
} else {
|
||||||
|
let page = DashboardPage(connection: connection) { [weak self] in self?.window }
|
||||||
|
page.onChange = { [weak self, weak page] in
|
||||||
|
if let page { self?.pageChanged(page) }
|
||||||
|
}
|
||||||
|
pages[connection.id] = page
|
||||||
|
// A dashboard behind the one on screen still lays itself out
|
||||||
|
// at the window's size, not at nothing.
|
||||||
|
page.view.frame = NSRect(x: 0, y: 0, width: content.bounds.width,
|
||||||
|
height: max(content.bounds.height - pageAreaTop.constant, 0))
|
||||||
|
page.view.layoutSubtreeIfNeeded()
|
||||||
|
page.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order = connections.map(\.id)
|
||||||
|
if selection.map(ids.contains) != true { selection = order.first }
|
||||||
|
showSelection()
|
||||||
|
refreshTabs()
|
||||||
|
placeTabBar()
|
||||||
|
if hasTabs && !hadTabs { flashTabBar(for: 3) } // out long enough to be noticed
|
||||||
|
updateDisplayAwake(settings.keepDisplayAwake)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pageChanged(_ page: DashboardPage) {
|
||||||
|
refreshTabs()
|
||||||
|
if page === currentPage {
|
||||||
|
updateTitle()
|
||||||
|
// A new address means a new web view: put it on screen.
|
||||||
|
if page.view.superview !== pageArea { showSelection() }
|
||||||
|
// The web view can't take the keyboard until it's shown something.
|
||||||
|
if page.status.phase == .showing, (window?.firstResponder as? NSView)?.isDescendant(of: page.webView) != true {
|
||||||
|
window?.makeFirstResponder(page.webView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSelection() {
|
||||||
|
let page = currentPage
|
||||||
|
for view in pageArea.subviews where view !== page?.view { view.removeFromSuperview() }
|
||||||
|
if let page, page.view.superview !== pageArea {
|
||||||
|
page.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
pageArea.addSubview(page.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
page.view.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor),
|
||||||
|
page.view.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor),
|
||||||
|
page.view.topAnchor.constraint(equalTo: pageArea.topAnchor),
|
||||||
|
page.view.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
emptyState.isHidden = page != nil
|
||||||
|
if let page { window?.makeFirstResponder(page.webView) }
|
||||||
|
settings.selectedConnection = selection
|
||||||
|
updateTitle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTitle() {
|
||||||
|
window?.title = currentPage?.connection.displayName ?? "ProdDash Viewer"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshTabs() {
|
||||||
|
tabModel.tabs = order.compactMap { id in
|
||||||
|
pages[id].map {
|
||||||
|
TabBarModel.Tab(id: id, title: $0.connection.displayName,
|
||||||
|
address: $0.connection.displayAddress, unreachable: $0.isUnreachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tabModel.selection = selection
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ id: UUID) {
|
||||||
|
guard pages[id] != nil else { return }
|
||||||
|
if id != selection {
|
||||||
|
selection = id
|
||||||
|
showSelection()
|
||||||
|
refreshTabs()
|
||||||
|
}
|
||||||
|
flashTabBar(for: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func select(index: Int) {
|
||||||
|
guard order.indices.contains(index) else { return }
|
||||||
|
select(order[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func step(_ delta: Int) {
|
||||||
|
guard !order.isEmpty else { return }
|
||||||
|
let index = selection.flatMap { order.firstIndex(of: $0) } ?? 0
|
||||||
|
select(index: (index + delta + order.count) % order.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 (⌘9 the last), as in Safari. Not menu items:
|
||||||
|
/// the menus stay as short as the viewer is.
|
||||||
|
private func installKeyboardSwitching() {
|
||||||
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||||
|
guard let self, event.window === self.window, self.hasTabs else { return event }
|
||||||
|
// Only the keys that are pressed together — Caps Lock doesn't count.
|
||||||
|
let mods = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||||
|
if event.keyCode == 48, mods.subtracting(.shift) == .control { // Tab
|
||||||
|
self.step(mods.contains(.shift) ? -1 : 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mods == .command, let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...9).contains(digit) {
|
||||||
|
self.select(index: digit == 9 ? self.order.count - 1 : digit - 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The tab bar coming and going
|
||||||
|
|
||||||
|
/// Overlay in a window, titlebar accessory in full screen — and neither
|
||||||
|
/// with only one connection. The accessory stays attached whenever there
|
||||||
|
/// are tabs, so it is already in place when AppKit builds the full-screen
|
||||||
|
/// title bar; in a window it is hidden, and hidden it takes no room, so
|
||||||
|
/// the page stays put.
|
||||||
|
private func placeTabBar() {
|
||||||
|
tabAccessory.isHidden = !inFullScreen
|
||||||
|
if hasTabs != accessoryAttached {
|
||||||
|
accessoryAttached = hasTabs
|
||||||
|
if hasTabs {
|
||||||
|
window?.addTitlebarAccessoryViewController(tabAccessory)
|
||||||
|
} else {
|
||||||
|
tabAccessory.removeFromParent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTabs {
|
||||||
|
hideTimer?.invalidate()
|
||||||
|
setTabBar(shown: false, animated: false)
|
||||||
|
}
|
||||||
|
content.updateTrackingAreas()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under the title bar in a window; the top of the screen in full screen,
|
||||||
|
/// where it only flashes up after a switch.
|
||||||
|
private var shownOverlayTop: CGFloat { inFullScreen ? 0 : Self.titlebarHeight }
|
||||||
|
/// Slid up by a few points while hidden, so it slides down as it fades in.
|
||||||
|
private var hiddenOverlayTop: CGFloat { shownOverlayTop - 8 }
|
||||||
|
|
||||||
|
/// The strip along the top that brings the bar out: the title bar, a
|
||||||
|
/// sliver of the dashboard below it, and the bar itself while it's out.
|
||||||
|
private var revealZoneHeight: CGFloat {
|
||||||
|
guard hasTabs, !inFullScreen else { return 0 }
|
||||||
|
return Self.titlebarHeight + (tabBarShown ? Self.tabBarHeight : 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pointerInTopZone(_ inside: Bool) {
|
||||||
|
if inside {
|
||||||
|
hideTimer?.invalidate()
|
||||||
|
setTabBar(shown: true)
|
||||||
|
} else {
|
||||||
|
scheduleHide(after: 0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the bar out for a moment — at launch, and after switching, so
|
||||||
|
/// it's clear what changed. In full screen too, where nothing else says
|
||||||
|
/// which installation is on screen.
|
||||||
|
private func flashTabBar(for seconds: TimeInterval) {
|
||||||
|
guard hasTabs else { return }
|
||||||
|
setTabBar(shown: true)
|
||||||
|
if !content.pointerInTopZone { scheduleHide(after: seconds) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleHide(after delay: TimeInterval) {
|
||||||
|
hideTimer?.invalidate()
|
||||||
|
hideTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
|
||||||
|
guard let self, !self.content.pointerInTopZone else { return }
|
||||||
|
self.setTabBar(shown: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setTabBar(shown: Bool, animated: Bool = true) {
|
||||||
|
let shown = shown && hasTabs
|
||||||
|
guard shown != tabBarShown else { return }
|
||||||
|
tabBarShown = shown
|
||||||
|
if shown { tabOverlay.isHidden = false }
|
||||||
|
content.layoutSubtreeIfNeeded()
|
||||||
|
NSAnimationContext.runAnimationGroup({ context in
|
||||||
|
context.duration = animated ? 0.18 : 0
|
||||||
|
context.allowsImplicitAnimation = true
|
||||||
|
tabOverlay.animator().alphaValue = shown ? 1 : 0
|
||||||
|
tabOverlayTop.animator().constant = shown ? shownOverlayTop : hiddenOverlayTop
|
||||||
|
content.layoutSubtreeIfNeeded()
|
||||||
|
}, completionHandler: { [weak self] in
|
||||||
|
guard let self, !self.tabBarShown else { return }
|
||||||
|
self.tabOverlay.isHidden = true
|
||||||
|
})
|
||||||
|
content.updateTrackingAreas()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full screen
|
||||||
|
|
||||||
|
func windowWillEnterFullScreen(_ notification: Notification) {
|
||||||
|
settleOverlay()
|
||||||
|
inFullScreen = true
|
||||||
|
pageAreaTop.constant = 0
|
||||||
|
placeTabBar()
|
||||||
|
tabOverlayTop.constant = hiddenOverlayTop
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowDidEnterFullScreen(_ notification: Notification) {
|
||||||
|
if !leaving { settings.reopenInFullScreen = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accessory hides before the window is a window again — showing in
|
||||||
|
/// a window's title bar, it would push the dashboard down.
|
||||||
|
func windowWillExitFullScreen(_ notification: Notification) {
|
||||||
|
settleOverlay()
|
||||||
|
inFullScreen = false
|
||||||
|
pageAreaTop.constant = Self.titlebarHeight
|
||||||
|
placeTabBar()
|
||||||
|
tabOverlayTop.constant = hiddenOverlayTop
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No overlay riding through the full-screen animation.
|
||||||
|
private func settleOverlay() {
|
||||||
|
hideTimer?.invalidate()
|
||||||
|
setTabBar(shown: false, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowDidExitFullScreen(_ notification: Notification) {
|
||||||
|
if !leaving { settings.reopenInFullScreen = false }
|
||||||
|
content.updateTrackingAreas()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called on the way out, before AppKit takes the window out of full
|
||||||
|
/// screen on its own account.
|
||||||
|
func rememberFullScreen() {
|
||||||
|
guard !leaving, let window else { return }
|
||||||
|
leaving = true
|
||||||
|
settings.reopenInFullScreen = window.styleMask.contains(.fullScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window
|
||||||
|
|
||||||
|
/// Before AppKit takes a full-screen window out of full screen to close it.
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
|
rememberFullScreen()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One window is the whole app: closing it quits, as in System Settings.
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
rememberFullScreen()
|
||||||
|
DispatchQueue.main.async { NSApp.terminate(nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowDidChangeOcclusionState(_ notification: Notification) {
|
||||||
|
updateDisplayAwake(settings.keepDisplayAwake)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the display awake while a dashboard is actually on screen — not
|
||||||
|
/// while the window is minimised, hidden or covered.
|
||||||
|
private func updateDisplayAwake(_ keep: Bool) {
|
||||||
|
let visible = window?.occlusionState.contains(.visible) == true
|
||||||
|
let wanted = keep && visible && !order.isEmpty
|
||||||
|
if wanted, awakeActivity == nil {
|
||||||
|
awakeActivity = ProcessInfo.processInfo.beginActivity(
|
||||||
|
options: [.idleDisplaySleepDisabled], reason: "Showing a ProdDash dashboard")
|
||||||
|
} else if !wanted, let activity = awakeActivity {
|
||||||
|
ProcessInfo.processInfo.endActivity(activity)
|
||||||
|
awakeActivity = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View menu
|
||||||
|
|
||||||
|
@objc func goBackInPage(_ sender: Any?) { currentPage?.goBack() }
|
||||||
|
@objc func goForwardInPage(_ sender: Any?) { currentPage?.goForward() }
|
||||||
|
@objc func reloadPage(_ sender: Any?) { currentPage?.reload() }
|
||||||
|
|
||||||
|
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||||
|
switch item.action {
|
||||||
|
case #selector(goBackInPage(_:)): return currentPage?.webView.canGoBack == true
|
||||||
|
case #selector(goForwardInPage(_:)): return currentPage?.webView.canGoForward == true
|
||||||
|
case #selector(reloadPage(_:)): return currentPage != nil
|
||||||
|
default: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tab bar as it shows in a window: the title bar's material, the tabs,
|
||||||
|
/// and a hairline under them, floating over the top of the dashboard.
|
||||||
|
final class TabBarOverlay: NSView {
|
||||||
|
|
||||||
|
init(model: TabBarModel) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
let material = NSVisualEffectView()
|
||||||
|
material.material = .titlebar
|
||||||
|
material.blendingMode = .withinWindow
|
||||||
|
material.state = .followsWindowActiveState
|
||||||
|
let tabs = TabBarHostingView(rootView: TabBar(model: model))
|
||||||
|
tabs.sizingOptions = []
|
||||||
|
let rule = Hairline()
|
||||||
|
for view in [material, tabs, rule] {
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(view)
|
||||||
|
}
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
material.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
material.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
material.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
material.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
tabs.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
tabs.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
tabs.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
tabs.bottomAnchor.constraint(equalTo: rule.topAnchor),
|
||||||
|
rule.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
rule.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
rule.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
rule.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("not used") }
|
||||||
|
|
||||||
|
/// The title bar's separator line. (An NSBox separator would do, but one
|
||||||
|
/// laid out from a zero frame decides it's vertical and pins the window
|
||||||
|
/// to a point wide.)
|
||||||
|
private final class Hairline: NSView {
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
NSColor.separatorColor.setFill()
|
||||||
|
bounds.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window's content view. It watches the strip along the top of the
|
||||||
|
/// window — which, with the content drawn under the title bar, includes the
|
||||||
|
/// title bar — and says when the pointer comes and goes. Tracking stays on
|
||||||
|
/// while another app is in front, so the bar answers the pointer even then.
|
||||||
|
final class ViewerContentView: NSView {
|
||||||
|
|
||||||
|
var topZoneHeight: () -> CGFloat = { 0 }
|
||||||
|
var onPointerInTopZone: (Bool) -> Void = { _ in }
|
||||||
|
private var zone: NSTrackingArea?
|
||||||
|
|
||||||
|
private var zoneRect: NSRect {
|
||||||
|
let height = min(topZoneHeight(), bounds.height)
|
||||||
|
return NSRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pointerInTopZone: Bool {
|
||||||
|
guard let window else { return false }
|
||||||
|
let rect = zoneRect
|
||||||
|
return rect.height > 0 && rect.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func updateTrackingAreas() {
|
||||||
|
super.updateTrackingAreas()
|
||||||
|
if let zone { removeTrackingArea(zone) }
|
||||||
|
zone = nil
|
||||||
|
let rect = zoneRect
|
||||||
|
guard rect.height > 0 else { return }
|
||||||
|
let area = NSTrackingArea(rect: rect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
|
||||||
|
addTrackingArea(area)
|
||||||
|
zone = area
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseEntered(with event: NSEvent) {
|
||||||
|
if event.trackingArea === zone { onPointerInTopZone(true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseExited(with event: NSEvent) {
|
||||||
|
if event.trackingArea === zone { onPointerInTopZone(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window before any connection exists.
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
|
||||||
|
let add: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Image(nsImage: NSApp.applicationIconImage)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 96, height: 96)
|
||||||
|
Text("No ProdDash Connections")
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
Text("Add the address of a ProdDash installation and its dashboard opens here. Add more than one to switch between them.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: 380)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Button("Add Connection…", action: add)
|
||||||
|
.controlSize(.large)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// ProdDash Viewer — a window onto ProdDash, and nothing else.
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
let application = NSApplication.shared
|
||||||
|
application.delegate = delegate
|
||||||
|
application.setActivationPolicy(.regular)
|
||||||
|
application.run()
|
||||||
Executable
+149
@@ -0,0 +1,149 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build ProdDash Viewer.app — a window onto ProdDash installations, for Apple
|
||||||
|
# silicon Macs. Native Swift, no dependencies.
|
||||||
|
#
|
||||||
|
# ./build.sh build/ProdDash Viewer.app
|
||||||
|
# ./build.sh --dmg …and build/ProdDash-Viewer-<version>-arm64.dmg
|
||||||
|
# ./build.sh --install …and copy it to /Applications
|
||||||
|
# ./build.sh --run …and launch it
|
||||||
|
# ./build.sh --notarize with --dmg: notarise and staple (needs a
|
||||||
|
# Developer ID and a notarytool keychain profile)
|
||||||
|
#
|
||||||
|
# Signing works as ProdDash's own app does (launcher/macos/README.md): a
|
||||||
|
# "Developer ID Application" certificate when one is installed (override with
|
||||||
|
# CODESIGN_IDENTITY), ad-hoc when none is — fine on your own Macs, but another
|
||||||
|
# Mac asks before it opens an ad-hoc app.
|
||||||
|
#
|
||||||
|
# Needs Xcode (xcode-select pointing at it): with the command line tools
|
||||||
|
# alone, the macOS 27 SDK can't expand SwiftUI's @State macro.
|
||||||
|
set -e -u
|
||||||
|
SCRIPT="${0:A}"
|
||||||
|
cd "${SCRIPT:h}"
|
||||||
|
|
||||||
|
APP_NAME="ProdDash Viewer"
|
||||||
|
EXECUTABLE="ProdDashViewer"
|
||||||
|
BUNDLE_ID="org.waterschurch.proddash.viewer"
|
||||||
|
VERSION="1.0.0"
|
||||||
|
ARCH="arm64" # Apple silicon
|
||||||
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
|
||||||
|
OUT_DIR="build"
|
||||||
|
APP="$OUT_DIR/$APP_NAME.app"
|
||||||
|
CONTENTS="$APP/Contents"
|
||||||
|
|
||||||
|
INSTALL=0
|
||||||
|
RUN=0
|
||||||
|
MAKE_DMG=0
|
||||||
|
NOTARIZE=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--install) INSTALL=1 ;;
|
||||||
|
--run) RUN=1 ;;
|
||||||
|
--dmg) MAKE_DMG=1 ;;
|
||||||
|
--notarize) NOTARIZE=1; MAKE_DMG=1 ;;
|
||||||
|
-h|--help) sed -n '2,18p' "$SCRIPT"; exit 0 ;;
|
||||||
|
*) echo "build.sh: unknown option $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v swiftc >/dev/null || {
|
||||||
|
echo "build.sh: swiftc not found — install Xcode, then: sudo xcode-select -s /Applications/Xcode.app" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "$APP_NAME $VERSION — building for $ARCH"
|
||||||
|
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||||
|
|
||||||
|
echo " compiling"
|
||||||
|
swiftc -O -whole-module-optimization \
|
||||||
|
-target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \
|
||||||
|
-module-name ProdDashViewer \
|
||||||
|
-o "$CONTENTS/MacOS/$EXECUTABLE" \
|
||||||
|
Sources/*.swift
|
||||||
|
|
||||||
|
cp Resources/Info.plist "$CONTENTS/Info.plist"
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||||
|
printf 'APPL????' > "$CONTENTS/PkgInfo"
|
||||||
|
|
||||||
|
cp -R Resources/Help "$CONTENTS/Resources/Help"
|
||||||
|
|
||||||
|
# Resources/AppIcon.png is drawn by Resources/make-icon.swift — ProdDash's
|
||||||
|
# tile mark on a display, so the Viewer and ProdDash itself tell apart.
|
||||||
|
echo " building the icon"
|
||||||
|
ICONSET="$OUT_DIR/AppIcon.iconset"
|
||||||
|
mkdir -p "$ICONSET"
|
||||||
|
for size in 16 32 128 256 512; do
|
||||||
|
sips -z $size $size Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||||
|
double=$((size * 2))
|
||||||
|
sips -z $double $double Resources/AppIcon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||||
|
done
|
||||||
|
iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns"
|
||||||
|
rm -rf "$ICONSET"
|
||||||
|
find "$APP" -name '.DS_Store' -delete
|
||||||
|
|
||||||
|
# ── signing ──────────────────────────────────────────────────────────────
|
||||||
|
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||||
|
if [[ -z "$IDENTITY" ]]; then
|
||||||
|
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||||
|
fi
|
||||||
|
if [[ -n "$IDENTITY" ]]; then
|
||||||
|
echo " signing as $IDENTITY"
|
||||||
|
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||||
|
else
|
||||||
|
echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)"
|
||||||
|
IDENTITY="-"
|
||||||
|
SIGN=(--force --sign -)
|
||||||
|
fi
|
||||||
|
codesign "${SIGN[@]}" --identifier "$BUNDLE_ID" "$APP"
|
||||||
|
codesign --verify --deep --strict "$APP" # stops the build if it doesn't
|
||||||
|
echo " signature verifies"
|
||||||
|
|
||||||
|
echo "built $APP ($(du -sh "$APP" | awk '{print $1}'))"
|
||||||
|
|
||||||
|
# ── disk image ───────────────────────────────────────────────────────────
|
||||||
|
if [[ $MAKE_DMG -eq 1 ]]; then
|
||||||
|
DMG="$OUT_DIR/ProdDash-Viewer-$VERSION-$ARCH.dmg"
|
||||||
|
STAGE="$OUT_DIR/dmg"
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
cp -R "$APP" "$STAGE/"
|
||||||
|
ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way
|
||||||
|
hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \
|
||||||
|
-ov -format UDZO -quiet "$DMG"
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
[[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG"
|
||||||
|
echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))"
|
||||||
|
|
||||||
|
if [[ $NOTARIZE -eq 1 ]]; then
|
||||||
|
if [[ "$IDENTITY" == "-" ]]; then
|
||||||
|
echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PROFILE="${NOTARY_PROFILE:-proddash}"
|
||||||
|
echo " notarising as keychain profile \"$PROFILE\" (a few minutes)"
|
||||||
|
xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait
|
||||||
|
xcrun stapler staple "$DMG"
|
||||||
|
echo " notarised and stapled — this opens anywhere with no warning"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $INSTALL -eq 1 ]]; then
|
||||||
|
DEST="/Applications/$APP_NAME.app"
|
||||||
|
if pgrep -x "$EXECUTABLE" >/dev/null; then
|
||||||
|
osascript -e "tell application id \"$BUNDLE_ID\" to quit" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
rm -rf "$DEST"
|
||||||
|
cp -R "$APP" "$DEST"
|
||||||
|
echo "installed $DEST"
|
||||||
|
APP="$DEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ $RUN -eq 1 ]] && open "$APP"
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user