Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 12f6e22187 An update that goes wrong is no longer the end of it
Two things the audit found, both about the archive path every shipped .app
uses.

**Settings could be wiped.** The update kept config/modules.json and
config/layouts/ by name, but not config/proddash.json — which holds the admin
PIN, the theme and the forge pointer. On an install whose data directory IS
the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR
aimed inside the app), an update wrote the repo's shipped defaults over it:
the theme went, and admin silently unlocked. Nothing inside the data
directory is written now, wherever it happens to sit, and the two legacy
names stay listed for an install whose state never moved out.

**A bad update was unrecoverable.** No rollback, and a fresh .app would not
help: seeding only replaces an *older* copy, and a broken one carries a
higher version — so the fix was deleting a folder nothing told you about.
Three changes, in the order they catch things:

- The download must parse before anything is swapped in. `node --check`
  against the new server.js, and a failure is reported with the syntax error
  and leaves the running copy untouched.
- Past that, the copy being replaced is set aside as `app.previous`, and
  dropped once the new version has stayed up ninety seconds — so that folder
  existing means "the last update has not proven itself yet", and never
  anything older.
- The launcher, which already gives up after four stops in two minutes, now
  looks for that folder before it does and puts the old copy back. Once: if
  the old one will not run either, the update was not the problem.

The launcher half only reaches a machine with a new .app; the server half
travels by the normal update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:16:46 -04:00
michael 5025e12b71 Merge pull request 'Rules for a clean interface, and the modules brought to them' (#9) from claude/module-rules into main 2026-09-17 15:52:59 -04:00
michael 3009b39453 Merge pull request 'Twelve themes, and the Theme section folds away' (#8) from claude/themes into main 2026-09-17 15:52:24 -04:00
michael 41f64f3a13 Merge pull request 'Admin PIN: the fields say enough' (#7) from claude/admin-pin-trim into main 2026-09-17 15:51:34 -04:00
Waters ChurchandClaude Opus 5 12f0b20e5f Rules for a clean interface, and the modules brought to them
Seven rules join the four in the module guide, as rules 5 to 11: no
explanatory text in a tile's gear and no label over three words; server-side
settings in configSchema only; a tile setting changes that tile alone;
hide-toggles ungrouped when there are four or fewer and in a folded "Show"
group when there are five or more; title-bar controls are icons with
tooltips, never names; anything needing words belongs in the gear; and one
control per action, either place but not both.

Two passages said the opposite and now say the same thing — "keep labels
short and put the explanation there" is scoped to configSchema, and "icon
buttons, short labels" becomes icons with no labels at all. CLAUDE.md
carries the summary.

Then the modules. Nothing here changes a setting key or a default, so saved
layouts, named layouts and every tile already on a wall keep working; group
names, labels, help and fold state are presentation and are not stored.

- pco-plan: sixteen labels cut to three words or fewer ("Tint items by type
  (song / media / other)" is now "Tint by type"), "Show / hide" renamed
  "Show" and folded. Its one remaining help line stays by request: the top
  bar's template needs its separator syntax spelled out, which the variable
  chips beside it cannot convey, so rule 5 carves out template fields.
- propresenter-timers: the "Timers ▾" menu was the only named control left
  in any title bar. It is a stopwatch icon now, with the count riding
  alongside it while some timers are hidden — setLabel() writes over the
  button's contents, so the count needed ProdCom's badge treatment rather
  than a label. "Show" folds; the Colours help goes.
- propresenter-now-next: "Show" folds, in the manifest and in the Now
  entry's own copy.
- prodcom-transcript: "Show channel icons" loses the redundant verb.

slack and clock were already compliant and are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:22:06 -04:00
Waters ChurchandClaude Opus 5 ea8be2ac76 Admin PIN: the fields say enough
The paragraph under the PIN fields explained what the PIN covers and how to
clear it by hand — and only someone already past the PIN could read it, so
the recovery note was never where it was needed. Gone, with the dead style
and the element lookup that hung off it.

The live line under the heading stays: it is the only thing that says
whether admin is locked, and the only warning that a passcode in the
server's environment outranks whatever is set here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:05:53 -04:00
17 changed files with 571 additions and 115 deletions
+6
View File
@@ -32,6 +32,12 @@ title-bar controls). The rules that matter most:
and bump the module's `version` on every change.
- Browsers never call upstream services directly; the module's server routes
proxy everything (no CORS, secrets stay server-side).
- **Keep the interface clean** (guide rules 5-11): no `help` and no label
over three words in a tile's gear; server-side settings in `configSchema`
only; a tile setting changes that tile alone; hide-toggles ungrouped when
there are four or fewer, in a folded **Show** group when there are five or
more; title-bar controls are icons with tooltips, never labels, and never
repeat something the gear already offers.
- Style with the shell's CSS variables only, scoped under
`[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`).
- Verify against the guide's testing section and resilience checklist. Mocks
+12 -1
View File
@@ -200,7 +200,18 @@ below. Nothing is installed without someone clicking.
no local changes — otherwise the banner says why and you update with git).
A plain folder (no `.git`) has the repo archive unpacked over it; files
removed upstream are left behind but harmless. Settings and layouts are
never touched. **What changed** opens the commit comparison on GitHub.
never touched — nothing inside the data directory is written, even on an
install where that directory *is* the app's own `config/`. **What changed**
opens the commit comparison on the forge.
- **If an update goes wrong**: the download has to parse before anything is
swapped in, so a truncated or mangled `server.js` is refused with the
reason and the running copy is left exactly as it was. Past that, the copy
being replaced is set aside as `app.previous` next to the app folder, and
dropped once the new version has stayed up for ninety seconds — so that
folder existing means "the last update has not proven itself yet". The
macOS app watches for a version that will not stay up (four stops in two
minutes) and puts the old one back by itself, once, with a notification.
Running some other way, the way back is a rename: `mv app.previous app`.
- **Restarting**: after an update the server exits with code 75 and starts
its own replacement, which waits for the port to free up. If you supervise
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
+62 -16
View File
@@ -34,6 +34,46 @@ people running the booth depend on them.
The admin page refuses to install a module whose requirement the running
shell doesn't meet, and it offers updates by comparing versions.
### Keeping the interface clean
A dashboard is read across a booth, at a glance, by someone doing three
other things. These rules are what keep every tile looking like one product
instead of six. They are as binding as the four above.
5. **No explanatory text in a tile's gear.** No `help` on an
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
words at most, one or two preferred** — "Slide count", "Item tags",
"Running clock". A control that cannot be understood in three words is
not a display choice; it belongs in `configSchema`, where `help` is
welcome and expected. *One exception:* a `template` field may keep a
single `help` line naming its placeholders, which nobody can guess.
6. **Server-side settings are admin-only.** Anything the module's server
acts on — a host, a credential, what to fetch, how often to poll — is
`configSchema`. `instanceSchema` is display choices and nothing else:
what this tile shows, and how big.
7. **A tile setting changes that tile and nothing else.** Never another
tile, never the module as a whole, never the server. If two tiles must
agree on something, it is either admin config or page state the shell
owns — not a setting each tile keeps its own copy of.
8. **Offer toggles for the elements you draw.** Every tile should let the
operator switch off the parts it does not need. **One to four toggles:**
give them no `group` at all, so they sit unlabelled at the top of the
gear. **Five or more:** one group named **Show**, `collapsed: true`,
`columns: 2`. Nothing else goes in that group — colours, formats and
text belong in groups of their own.
9. **Title-bar controls are icons.** No `label` on `addButton` or
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
the icon. A small state indicator may ride alongside an icon — ProdCom's
channel button shows a count while a filter is on — but a control is
never named in the bar.
10. **If a control needs words to explain it, it is not a title-bar
control.** Put it in the gear. The bar is for actions whose icon and
tooltip say everything: refresh, clear, jump to latest, bigger text.
11. **One control per action.** Whatever has a dedicated title-bar button is
not offered again in the gear, and the reverse. Text size is A− / A+ up
there, never a "Text size" field; a timestamps button in the bar means
no timestamps switch below it.
## The shape of a module
A module is one folder dropped into `modules/`:
@@ -177,14 +217,18 @@ the group name:
`help` on a field puts one line of guidance under its control — allowed
placeholders, where a value comes from. Keep labels short and put the
explanation there.
explanation there. **This is for `configSchema` only**: a tile's gear
carries no explanations at all (rule 5), so a setting that needs one is
telling you it belongs in admin.
`group` and `help` work on `instanceSchema` fields too, and a manifest
`group` works on `instanceSchema` fields too, and a manifest
`instanceGroups` map plays the `configGroups` role for the tile's gear
popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how
a module with a long show/hide checklist keeps the popover usable — see
`pco-plan`. Display choices belong there, per tile; admin config is for
connections. The popover itself is a small window beside the tile — sized to
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
per rule 5). That is how a module with a long show/hide checklist keeps the
popover usable: five or more hide-toggles go in one **Show** group, folded
by default, and fewer than five go in no group at all — see `pco-plan` for
the long form and `slack` for the short one. Display choices belong there,
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
its content, moved by the slim bar on top, closed by clicking anywhere else
or pressing Escape — so a tile never has to be big enough to hold its own
settings, and a change can be watched as it lands. Under everything you
@@ -354,17 +398,19 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
```
Everything you add is removed automatically when your instance stops, so a
remount never duplicates controls. Keep them compact (icon buttons, short
labels): the title bar is 32 px tall and shared with the tile's name. Draw
icons as inline SVG rather than characters — a glyph such as ✕ or + takes
its size from whatever font each browser picks; text-size buttons are a
drawn − and +.
remount never duplicates controls. **Icons only, no labels** (rule 9): the
title bar is 32 px tall and shared with the tile's name, and the tooltip
says what the icon means. Draw icons as inline SVG rather than characters —
a glyph such as ✕ or + takes its size from whatever font each browser
picks; text-size buttons are a drawn − and +. A small state indicator may
ride alongside an icon (ProdCom's channel button carries a count while a
filter is on); a name may not.
**One control per action.** Whatever has a dedicated header button is not
offered again in the gear popover: text size is A− / A+ up here (persisted
through `saveInstanceSettings({ textSize })`, read back with a sane
fallback), never a "Text size" field in `instanceSchema`; a timestamps or
newest-first toggle in the header means no such switch in the gear. The
**One control per action** (rule 11). Whatever has a dedicated header button
is not offered again in the gear popover: text size is − / + up here
(persisted through `saveInstanceSettings({ textSize })`, read back with a
sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
or newest-first toggle in the header means no such switch in the gear. The
gear is for display choices that have no button of their own.
One more thing to know: the whole title bar folds away when the dashboard
@@ -103,6 +103,45 @@ enum ProdDash {
}
}
/**
Go back to the copy ProdDash set aside before its last update.
A version that will not stay up is the one state this app cannot talk its
way out of: the admin page went down with it, and installing a fresh
`.app` does nothing, because seeding only replaces an *older* copy and a
broken one carries a higher version. So ProdDash writes `app.previous`
before it overwrites itself and drops it again once the new version has
stayed up — meaning that folder exists only while an update is still
unproven. This puts it back.
Returns the version restored, or nil when there is nothing to go back to.
*/
@discardableResult
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
let fm = FileManager.default
let previous = URL(fileURLWithPath: root.path + ".previous")
guard isRoot(previous) else { return nil }
let version = shellVersion(root: previous)
let named = version.isEmpty ? "(unknown version)" : version
let broken = URL(fileURLWithPath: root.path + ".broken")
do {
try? fm.removeItem(at: broken)
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
try fm.moveItem(at: previous, to: root)
makeWritable(root)
try? fm.removeItem(at: broken)
log("rolled ProdDash back to \(named) — the copy it replaced")
return named
} catch {
log("could not roll back to the previous copy: \(error.localizedDescription)")
// Leave something runnable behind rather than nothing at all.
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
try? fm.moveItem(at: broken, to: root)
}
return nil
}
}
/// Files copied out of a bundle can arrive read-only, and this copy is
/// exactly where ProdDash writes its own updates.
private static func makeWritable(_ root: URL) {
+19 -1
View File
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
private var restartAfterExit = false
private var startedAt: Date?
private var crashTimes: [Date] = []
/// One roll-back per bad update: a second would just swap copies forever.
private var rolledBack = false
private var pendingRestart: DispatchWorkItem?
private var healthTimer: Timer?
private var healthMisses = 0
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
child = nil
healthy = false
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
if restartAfterExit {
restartAfterExit = false
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
guard settings.restartOnCrash, crashTimes.count < 4 else {
// Before giving up: if an update is still unproven, the copy it
// replaced is sitting right there. Going back beats sitting dark,
// and only once — if the old one will not run either, the problem
// is not the update.
if settings.restartOnCrash, !rolledBack, let root = rootURL,
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
rolledBack = true
crashTimes.removeAll()
state = .stopped
message = "Rolled back to ProdDash \(restored) — starting…"
Notifier.shared.post(title: "ProdDash rolled back",
body: "The new version would not stay up. Back on \(restored).")
refreshEnvironment()
start()
return
}
let why = settings.restartOnCrash
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
: "ProdDash \(how)."
+245 -49
View File
@@ -1,15 +1,23 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.4.0",
"version": "1.5.0",
"proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services",
"provides": ["timers"],
"provides": [
"timers"
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 5, "h": 6 },
"minSize": {
"w": 3,
"h": 3
},
"defaultSize": {
"w": 5,
"h": 6
},
"configGroups": {
"Planning Center credentials": {
"collapsed": "whenSet",
@@ -48,7 +56,12 @@
"type": "select",
"label": "Plan",
"default": "",
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
"options": [
{
"value": "",
"label": "Automatic — today's plan, else the next upcoming"
}
],
"optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId"
},
@@ -82,8 +95,14 @@
"group": "Service timing",
"showWhen": "useDocumentTriggers",
"options": [
{ "value": "schedule", "label": "Scheduled service time" },
{ "value": "document", "label": "ProPresenter document" }
{
"value": "schedule",
"label": "Scheduled service time"
},
{
"value": "document",
"label": "ProPresenter document"
}
]
},
"serviceStartDocument": {
@@ -124,37 +143,94 @@
"Item notes": {
"toggle": "showNotes"
},
"Show / hide": {
"Show": {
"collapsed": true,
"columns": 3,
"help": "Switch off anything this tile should not show."
"columns": 2
}
},
"instanceSchema": {
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
"autoScroll": {
"type": "boolean",
"label": "Auto-scroll",
"default": true
},
"headerTemplate": {
"type": "template",
"label": "Top bar text",
"default": "{series} • {part} | {serviceType} • {date}",
"group": "Top bar",
"variables": [
{ "value": "{series}", "label": "Series" },
{ "value": "{part}", "label": "Part / plan title" },
{ "value": "{serviceType}", "label": "Service type" },
{ "value": "{date}", "label": "Date" },
{ "value": "{shortDate}", "label": "Short date" },
{ "value": "{time}", "label": "Service time" },
{ "value": "{length}", "label": "Planned length" },
{ "value": "{items}", "label": "Item count" }
{
"value": "{series}",
"label": "Series"
},
{
"value": "{part}",
"label": "Part / plan title"
},
{
"value": "{serviceType}",
"label": "Service type"
},
{
"value": "{date}",
"label": "Date"
},
{
"value": "{shortDate}",
"label": "Short date"
},
{
"value": "{time}",
"label": "Service time"
},
{
"value": "{length}",
"label": "Planned length"
},
{
"value": "{items}",
"label": "Item count"
}
],
"help": "Use • between items and | to start a second line."
},
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
"showNotes": { "type": "switch", "label": "Item notes", "default": true, "group": "Item notes" },
"keyColor": {
"type": "color",
"label": "Song key",
"default": "#4ea1ff",
"group": "Colors"
},
"headerColor": {
"type": "color",
"label": "Section headers",
"default": "#2ee59a",
"group": "Colors"
},
"currentColor": {
"type": "color",
"label": "Current item",
"default": "#2ee59a",
"group": "Colors"
},
"headerGradient": {
"type": "boolean",
"label": "Header fade",
"default": true,
"group": "Colors"
},
"tintByType": {
"type": "boolean",
"label": "Tint by type",
"default": false,
"group": "Colors"
},
"showNotes": {
"type": "switch",
"label": "Item notes",
"default": true,
"group": "Item notes"
},
"noteCategories": {
"type": "multiselect",
"label": "Categories",
@@ -163,29 +239,149 @@
"columns": 3,
"allByDefault": true
},
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
"showTopBar": {
"type": "boolean",
"label": "Top bar",
"default": true,
"group": "Show"
},
"showSeries": {
"type": "boolean",
"label": "Series name",
"default": true,
"group": "Show"
},
"showPart": {
"type": "boolean",
"label": "Part / title",
"default": true,
"group": "Show"
},
"showServiceType": {
"type": "boolean",
"label": "Service type",
"default": true,
"group": "Show"
},
"showDate": {
"type": "boolean",
"label": "Plan date",
"default": true,
"group": "Show"
},
"showServiceTime": {
"type": "boolean",
"label": "Service time",
"default": true,
"group": "Show"
},
"showTotalLength": {
"type": "boolean",
"label": "Total length",
"default": true,
"group": "Show"
},
"showRunningClock": {
"type": "boolean",
"label": "Running clock",
"default": true,
"group": "Show"
},
"showCountdown": {
"type": "boolean",
"label": "Countdown",
"default": true,
"group": "Show"
},
"showHeaders": {
"type": "boolean",
"label": "Section headers",
"default": true,
"group": "Show"
},
"showItemNumbers": {
"type": "boolean",
"label": "Item numbers",
"default": false,
"group": "Show"
},
"showStartTimes": {
"type": "boolean",
"label": "Start times",
"default": true,
"group": "Show"
},
"showLength": {
"type": "boolean",
"label": "Item length",
"default": true,
"group": "Show"
},
"showItemType": {
"type": "boolean",
"label": "Type badge",
"default": true,
"group": "Show"
},
"showKey": {
"type": "boolean",
"label": "Song key",
"default": true,
"group": "Show"
},
"showArrangement": {
"type": "boolean",
"label": "Arrangement",
"default": false,
"group": "Show"
},
"showSongAuthor": {
"type": "boolean",
"label": "Author / CCLI",
"default": false,
"group": "Show"
},
"showDescription": {
"type": "boolean",
"label": "Item description",
"default": true,
"group": "Show"
},
"showPreService": {
"type": "boolean",
"label": "Pre-service",
"default": true,
"group": "Show"
},
"showPostService": {
"type": "boolean",
"label": "Post-service",
"default": true,
"group": "Show"
},
"showNowIndicator": {
"type": "boolean",
"label": "Now indicator",
"default": true,
"group": "Show"
},
"showItemRuntime": {
"type": "boolean",
"label": "Item runtime",
"default": true,
"group": "Show"
},
"dimCompleted": {
"type": "boolean",
"label": "Dim completed",
"default": true,
"group": "Show"
},
"showLiveFooter": {
"type": "boolean",
"label": "Match line",
"default": true,
"group": "Show"
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.3.1",
"version": "1.3.2",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript",
"permissions": [
@@ -40,7 +40,7 @@
"instanceSchema": {
"showAvatars": {
"type": "boolean",
"label": "Show channel icons",
"label": "Channel icons",
"default": true
}
}
+74 -15
View File
@@ -1,22 +1,34 @@
{
"id": "propresenter-now-next",
"name": "ProPresenter",
"version": "1.1.0",
"version": "1.1.1",
"proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next",
"permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 4, "h": 5 },
"minSize": {
"w": 3,
"h": 3
},
"defaultSize": {
"w": 4,
"h": 5
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": { "host": "", "port": 1025 }
"default": {
"host": "",
"port": 1025
}
},
"password": {
"type": "password",
@@ -25,17 +37,64 @@
}
},
"instanceSchema": {
"cleanTitles": { "type": "switch", "label": "Tidy titles", "default": true },
"showPlaylist": { "type": "switch", "label": "Playlist", "default": true, "group": "Show" },
"showLive": { "type": "switch", "label": "Live status", "default": true, "group": "Show" },
"showNowLabel": { "type": "switch", "label": "Now label", "default": true, "group": "Show" },
"showSlideCount": { "type": "switch", "label": "Slide count", "default": true, "group": "Show" },
"showProgress": { "type": "switch", "label": "Progress bar", "default": true, "group": "Show" },
"showNext": { "type": "switch", "label": "Next list", "default": true, "group": "Show" },
"showNextLabel": { "type": "switch", "label": "Next label", "default": true, "group": "Show" },
"showTags": { "type": "switch", "label": "Item tags", "default": true, "group": "Show" }
"cleanTitles": {
"type": "switch",
"label": "Tidy titles",
"default": true
},
"showPlaylist": {
"type": "switch",
"label": "Playlist",
"default": true,
"group": "Show"
},
"showLive": {
"type": "switch",
"label": "Live status",
"default": true,
"group": "Show"
},
"showNowLabel": {
"type": "switch",
"label": "Now label",
"default": true,
"group": "Show"
},
"showSlideCount": {
"type": "switch",
"label": "Slide count",
"default": true,
"group": "Show"
},
"showProgress": {
"type": "switch",
"label": "Progress bar",
"default": true,
"group": "Show"
},
"showNext": {
"type": "switch",
"label": "Next list",
"default": true,
"group": "Show"
},
"showNextLabel": {
"type": "switch",
"label": "Next label",
"default": true,
"group": "Show"
},
"showTags": {
"type": "switch",
"label": "Item tags",
"default": true,
"group": "Show"
}
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }
"Show": {
"collapsed": true,
"columns": 2
}
}
}
+1 -1
View File
@@ -140,7 +140,7 @@ module.exports = {
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: false, columns: 2 } },
instanceGroups: { Show: { collapsed: true, columns: 2 } },
},
];
},
+18 -6
View File
@@ -15,7 +15,7 @@
Per-tile choices: the gear popover's static switches (whole sources,
headings, status lines, the clock's format, the state colours) and the
"Timers ▾" header menu, which hides any single timer — keyed `source:id`
timers header menu, which hides any single timer — keyed `source:id`
and saved with the layout via saveInstanceSettings. The header can be
hidden by the user, so the static switches stay the primary controls.
A solo tile's gear carries only the settings its one card can use (the
@@ -196,6 +196,10 @@ function defaultStatus(timer, value, tone) {
/* ── the tile ─────────────────────────────────────────────────────────── */
/* The header menu's icon: a stopwatch, for the list of timers behind it.
Title-bar controls carry no words (module guide, rule 9). */
const TIMERS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2h4"/><path d="M12 14V9"/><circle cx="12" cy="14" r="8"/></svg>';
export default function create({ root, moduleApi }) {
let state = null;
let feedOffline = false;
@@ -227,7 +231,7 @@ export default function create({ root, moduleApi }) {
}
/* Which picker entry this tile was added as (the module's tiles() list):
'' or 'all' = every timer, selectable via the Timers ▾ menu;
'' or 'all' = every timer, selectable via the timers menu;
'solo:<source>:<id>' = exactly one card, no menu, no headings. The
earlier entries 'timer:<uuid>' and 'ltc' map onto the new keys. */
const variant = String(moduleApi.variant || '');
@@ -283,8 +287,8 @@ export default function create({ root, moduleApi }) {
let menuEl = null; // the open menu's element (filled on each open)
const itemsMenu = solo ? null : moduleApi.header.addMenu({
label: 'Timers ▾',
title: 'Choose timers',
icon: TIMERS_SVG,
title: 'Timers',
build(menu) {
// the shell hands over an emptied element on every open
menu.classList.add('tm-menu');
@@ -292,6 +296,12 @@ export default function create({ root, moduleApi }) {
rebuildMenu(true);
},
});
// The icon carries the name; a count rides alongside it while some timers
// are hidden, so the bar says "filtered" without saying it in words.
const itemsCountEl = document.createElement('span');
itemsCountEl.className = 'tm-count';
itemsCountEl.hidden = true;
if (itemsMenu) itemsMenu.button.appendChild(itemsCountEl);
function menuRows() {
const rows = [];
@@ -319,7 +329,9 @@ export default function create({ root, moduleApi }) {
if (!itemsMenu) return;
const rows = menuRows();
const visible = rows.filter((r) => !hidden.has(r.key)).length;
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`);
const filtering = visible !== rows.length;
itemsCountEl.textContent = filtering ? `${visible}/${rows.length}` : '';
itemsCountEl.hidden = !filtering;
}
let menuSignature = '';
@@ -779,7 +791,7 @@ export default function create({ root, moduleApi }) {
let hint = '';
const groups = visibleGroups();
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.';
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
hintEl.hidden = !hint;
hintEl.textContent = hint;
groupsEl.hidden = Boolean(hint);
+3 -4
View File
@@ -1,7 +1,7 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.3.0",
"version": "1.4.0",
"proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules",
"provides": [
@@ -112,7 +112,7 @@
},
"instanceGroups": {
"Show": {
"collapsed": false,
"collapsed": true,
"columns": 2
},
"Clock": {
@@ -121,8 +121,7 @@
},
"Colours": {
"collapsed": true,
"columns": 2,
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
"columns": 2
}
},
"instanceSchema": {
+9 -1
View File
@@ -321,7 +321,7 @@
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
/* ── the timers menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item {
display: flex;
@@ -383,3 +383,11 @@
font-size: 12px;
white-space: nowrap;
}
/* the count beside the title-bar timers icon, while some are hidden */
[data-module="propresenter-timers"] .tm-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-timers"] .tm-count[hidden] { display: none; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.6.0",
"version": "1.7.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
-8
View File
@@ -580,11 +580,3 @@ details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
gap: 8px;
margin-left: auto;
}
.pin-note { margin-top: 12px; }
.pin-note code {
font-size: 0.92em;
padding: 1px 5px;
border-radius: 4px;
background: var(--bg);
border: 1px solid var(--border);
}
-1
View File
@@ -132,7 +132,6 @@ function renderPin(state) {
: set
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json';
}
function pinMessage(text, bad = false) {
-4
View File
@@ -94,10 +94,6 @@
<button class="btn" id="pin-clear" hidden>Turn off</button>
</div>
</div>
<p class="admin-copy pin-note">Only this page and the admin API ask for it — dashboards stay
open, and it is nothing to do with a module's own PIN, such as Slack's send PIN. Forgotten?
Remove <code>adminPin</code> from <code id="pin-file">proddash.json</code> in the settings
folder and restart.</p>
<div class="admin-status" id="pin-status"></div>
</div>
</section>
+80 -5
View File
@@ -1104,12 +1104,13 @@ function uninstallModule(id) {
/* — updating the shell itself — */
function copyTree(src, dst) {
function copyTree(src, dst, include = null) {
fs.mkdirSync(dst, { recursive: true });
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
if (include && !include(e.name)) continue;
const s = path.join(src, e.name);
const d = path.join(dst, e.name);
if (e.isDirectory()) copyTree(s, d);
if (e.isDirectory()) copyTree(s, d, include);
else fs.copyFileSync(s, d);
}
}
@@ -1129,25 +1130,83 @@ async function updateShell() {
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
} else {
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) {
if (!safeRelPath(rel) || keep(rel)) continue;
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
}
if (!fs.existsSync(path.join(staging, 'server.js'))) {
const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) {
fs.rmSync(staging, { recursive: true, force: true });
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
}
// Nothing is swapped in until the new server at least parses. A file that
// arrived truncated or mangled would otherwise be found only by a process
// that cannot start — the one state this app cannot talk its way out of.
try {
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
// node --check leads with the staging path and a caret diagram; the
// line that names the fault is the one worth repeating.
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
}
keepPreviousCopy();
copyTree(staging, ROOT);
fs.rmSync(staging, { recursive: true, force: true });
}
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
}
/* ── what an update must not touch ───────────────────────────────────
Anything inside the data directory. Normally it sits well outside the app
folder and none of this applies, but when it cannot (an unwritable
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
directory IS the app's config/ — and an update would then write the repo's
shipped defaults over this machine's own proddash.json, taking the admin
PIN, the theme and the forge with it. The two legacy names stay listed for
an install whose state never moved out. */
const DATA_DIR_INSIDE_ROOT = (() => {
const rel = path.relative(ROOT, DATA_DIR);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
return rel.split(path.sep).join('/') + '/';
})();
function isKeptOnUpdate(rel) {
if (rel.startsWith('.git/')) return true;
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
}
/* ── the way back ────────────────────────────────────────────────────
The copy being replaced is set aside first, so there is always one known
version to return to. The launcher restores it if the new one will not
stay up (see ServerController); by hand it is a rename. It is replaced by
the next update, never accumulated. */
function previousCopyPath() {
return `${ROOT}.previous`;
}
function keepPreviousCopy() {
const previous = previousCopyPath();
try {
fs.rmSync(previous, { recursive: true, force: true });
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
} catch (err) {
// Worth saying, not worth refusing the update over.
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
}
}
/**
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
@@ -1918,6 +1977,22 @@ server.on('error', (err) => {
server.listen(PORT, '0.0.0.0');
/* An update proves itself by staying up. Once this version has, the copy it
replaced is dropped — so `app.previous` existing always means "the last
update has not proven itself yet", which is exactly what the launcher
should roll back to and nothing more. */
const UPDATE_PROVEN_AFTER_MS = 90000;
setTimeout(() => {
const previous = previousCopyPath();
if (!fs.existsSync(previous)) return;
try {
fs.rmSync(previous, { recursive: true, force: true });
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
} catch (err) {
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
}
}, UPDATE_PROVEN_AFTER_MS).unref();
// Update check: shortly after boot, then every few hours (the admin page
// shows the result; nothing is installed without someone clicking).
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {