Compare commits

...

184 commits

Author SHA1 Message Date
screentinker 3f9459139f
Gate licences in CI and publish an SBOM (#282)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Licence gate + SBOM (production deps) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
The licence audit that found org.json in the APK was run by hand. Nothing stopped the next
transitive dependency arriving the same way, and "we track licences" was a claim rather than
something anyone could check.

TWO GATES, BOTH FAIL CLOSED.

scripts/license-check.js audits the server's npm tree. scripts/android-license-check.js
resolves the real releaseRuntimeClasspath — everything that can enter the APK a customer
installs — and checks it against android/licenses.json, where each entry records the licence
AND the evidence for it. A dependency nobody has recorded fails the build. That is the case
worth catching: org.json reached customers because it arrived transitively and nothing ever
asked what licence it carried.

Denied: AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence. Weak copyleft (LGPL,
MPL, EPL, CDDL) is reported but does not fail — it is a judgement, and the judgement should
be made by someone who knows they are making it. Anything unrecognised fails; a package whose
licence we cannot identify is not one we ship.

⚠️ THE SERVER GATE INSTALLS --omit=dev, AND THAT IS THE POINT. A developer checkout carries
sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later. It is a test fixture generator that
never reaches a server, but a scanner pointed at a dev tree reports LGPL and contradicts the
answer we give customers. Auditing the production install is what makes the answer defensible.

SBOM. Every release now publishes screentinker-sbom-<version>.cdx.json — CycloneDX 1.5, every
production dependency with version, purl and licence, generated from a production install. CI
uploads one on every run too. That is what turns the claim into something a customer or an
underwriter can verify themselves.

Neither script takes a dependency: a gate that needs its own supply chain audited is worth
less than one that does not.

Verified by mutation rather than assumed. Injecting GPL-3.0-or-later, AGPL-3.0, the JSON
Licence, SSPL-1.0, and a package with no licence field each fail the server gate; MIT and
LGPL pass (LGPL reported). Removing the org.json exclusion fails the Android gate by name;
dropping a group from the policy fails it as unrecorded. Both restored, both green.

Found and fixed while building it: npm ls exits non-zero for any tree problem — an extraneous
package is enough — which made the gate abort instead of auditing. It now reads the listing
either way and only aborts on genuinely empty output.

docs/licensing.md records the policy, how to run the gates, and the dev-vs-production trap.

1676/1676 pass.
2026-08-14 15:36:38 -05:00
screentinker 94a81b6896
Stop shipping a licence we would rather not have to explain (#281)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
An audit of what actually reaches customers turned up three things. None is copyleft — there
is no GPL or AGPL anywhere in the product — but two of them are the kind of detail that
undermines a claim to track licences at all.

org.json WAS BEING PACKAGED INTO THE APK. socket.io-client pulls org.json:json:20090211
transitively, and it was landing in the dex in full: 19 classes, including CDL, XML, JSONML
and the library's own Test class. That release carries the JSON Licence, whose "shall be used
for Good, not Evil" clause is not OSI-approved, is treated as non-free by Debian and Fedora,
and is Category X at Apache. Excluded now, and nothing is lost: Android has provided org.json
in the platform since API 1 and minSdk is 24.

Verified rather than assumed. The whole surface used — by socket.io/engine.io and by our own
Kotlin — is JSONObject, JSONArray and JSONTokener, via get/getString/getLong/getJSONArray/
getJSONObject/has/keys/length/isNull/put/NULL, the opt* family, and JSONTokener.nextValue.
Every one is platform API. The rebuilt APK defines 0 org.json classes (was 19) while still
referencing all three, so they now resolve against the platform; it is 25KB smaller and the
v1 JAR signature is intact. Then it was installed on a real panel, which registered over the
socket, paired, and parsed a playlist with no NoSuchMethodError — the failure mode that would
only ever appear at runtime.

REDOC SHIPPED WITH NO LICENCE NOTICE. frontend/vendor/redoc.standalone.js is in the release
tarball, minified with every header stripped, and the vendor README recorded version and
source but not licence. MIT requires the notice to travel with the software. Added as
redoc.LICENSE, with a note in the README that anything vendored here ships and therefore
needs one.

The server manifest declared no licence at all, though the repo is MIT — tooling and auditors
read package.json, not just the root LICENSE. Set, and the lockfile synced so `npm ci` cannot
disagree.

Audited against production's own installed tree rather than a developer checkout: 365
packages, `COPYLEFT — none`.

1676/1676 server tests, Android build + unit tests, `npm ci` clean.
2026-08-14 14:50:18 -05:00
ScreenTinker 86db5929c1 chore(release): v1.9.36 2026-08-14 12:23:16 -05:00
screentinker 3ec06b663c
Changelog for 1.9.36 (#280)
A crash on collector installs, and the check that should have caught it. Says plainly
who is affected (almost nobody: only a server configured to collect statistics from
other installs, not one reporting its own) so a self-hoster on 1.9.35 is not alarmed
into an unnecessary upgrade.
2026-08-14 12:22:36 -05:00
screentinker 8cb67122ad
Fix a load-time crash that took down any install collecting statistics (#279)
1.9.35 could not start with TELEMETRY_COLLECTOR=1. It threw before listening:

  ReferenceError: Cannot access 'db' before initialization
      at server.js:986

and systemd restarted it in a loop. Production was down until it was rolled back.

The mount passed the module-scope `db` to the collector's factory, but that binding is
declared ~275 lines further down. The inline handler this replaced only touched `db`
inside a request callback — which runs long after the binding exists — so moving the same
reference into a factory argument turned a lazy read into an eager one. Now resolved as
`require('./db/database').db`, the way every neighbouring call site in that region does it.

WHY NOTHING CAUGHT IT. The block is gated on a flag that only the statistics-collecting
deployment sets. It had therefore never executed in CI, on alpha, or in any test — 1676
tests, four green jobs, a clean alpha deploy, and the crashing line had still never run.
The unit tests mount the router directly and pass a db, which is precisely the part that
was fine.

So the boot smoke now boots WITH the collector enabled and asserts its routes answer:
/api/public/stats returns the expected shape, and a malformed report is refused with 400.
Booting alone would not be enough — the collector could mount and be broken.

Confirmed by reproduction: the released code fails to boot under that flag, and this does
not. 1676/1676 pass.
2026-08-14 12:17:41 -05:00
ScreenTinker b13f11af13 chore(release): v1.9.35 2026-08-14 11:12:20 -05:00
screentinker dd7295792e
Changelog for 1.9.35 (#278)
Two faults where the product was working correctly and still looked broken to whoever
was standing in front of the screen — a player retrying an update it could never apply,
and a directory panel showing the phone keyboard over the one it draws itself — plus the
dependency advisories that can reach a running server.

Written before the cut so the release page has something to publish: since #273 the notes
come from this file, and a missing entry falls back to commit subjects.
2026-08-14 11:11:39 -05:00
screentinker 114dc453bb
Show screens deployed on the landing page (#277)
The number exists — every install that opts into sharing reports its screen count, and
the collector has been keeping them since it went live. Nothing read them back out.

GET /api/public/stats returns the aggregate: total screens and how many installs they
came from. The landing page shows it under the hero and stays silent otherwise — hidden
until a number arrives, so a self-hosted instance (where the route does not exist) and a
brand-new one (where the count is zero) show nothing rather than an empty frame or a "0".

Gated on TELEMETRY_COLLECTOR, the same flag as the collector, and the gate is doing real
work here: without it, any anonymous visitor could read a private instance's screen count
off its own landing page. Only the deployment that gathers the numbers may state them, and
there the figure is a sum across every reporting install, so it discloses nothing about any
one of them. Verified with the flag unset: both routes 404.

Cached for five minutes. This sits on a public page and the number moves in hours, so a
scraper in a loop costs one query per interval rather than one per request.

Both endpoints moved out of server.js into routes/telemetry-collector.js as an injectable
factory. They were inline and therefore untestable — an unauthenticated endpoint anyone on
the internet can POST to, and the one that decides what a public page claims, with no test
between them. Now covered: the upsert really updates (an install reporting daily must not
become 365 rows and get counted 365 times), malformed and hostile bodies are refused
without reaching the table, the aggregate carries no per-install detail, and the cache
holds.

1676/1676 pass.
2026-08-14 10:07:50 -05:00
screentinker 955a691bcd
Clear the high-severity advisories that reach production (#276)
npm audit reports 8 high findings. Four of them reach production; the other four are
dev-only and cannot, because prod installs with --omit=dev. Verified rather than
assumed: puppeteer-core, extract-zip, @puppeteer/browsers and js-yaml are all absent
from prod's node_modules.

Three of the four are transitive, and the fix is a patch or minor inside the range
package.json already declares — no API moves, and package.json is untouched by them:

  brace-expansion    2.1.2  -> 2.1.4    (archiver -> glob/minimatch)
  ip-address         10.2.0 -> 10.5.0   (express-rate-limit)
  socket.io-parser   4.2.6  -> 4.2.7    (socket.io)

socket.io-parser was the one worth checking, because a parser change that altered the
wire format would break every deployed player at once rather than fail a test. It does
not: socket.io stays at 4.8.3, engine.io at 6.6.9, and the parser's protocol constant
is still 5. Nothing a player speaks changes.

The fourth is a real bump — nodemailer 6.10.1 -> 9.0.5, across three majors, closing
eight advisories including SMTP command injection and header injection. Our surface is
about as small as it gets: createTransport({host, port, secure, auth}) and sendMail with
from/to/subject/text/html. Engine requirements are unchanged (>=6.0.0), and the entry
point is the same.

The existing email tests mock nodemailer through require.cache, so they would have
stayed green through any breaking change in the library itself — proven, not guessed:
with sendMail patched to throw, those 15 tests still pass. So this adds a test that
drives the REAL library over a loopback SMTP server and asserts on the conversation,
using messages built by our own buildSmtpMessage rather than hand-written ones. That
test does fail against the broken build.

Left alone: extract-zip under puppeteer-core, now with no fix available. It is a
devDependency used only by smoke-ui.js, which already no-ops when it is missing, and the
advisory is symlink traversal while unpacking a downloaded browser — puppeteer-core with
an explicit executablePath never downloads or extracts one.

Production audit goes from 4 high to 0. 1671/1671 pass.
2026-08-14 09:47:08 -05:00
screentinker 702e107972
Directory search: don't let the platform keyboard cover our own (#275)
The directory-search widget draws its own on-screen keyboard, on by default, sized
and themed to the panel. On Android it was never visible: the page autofocuses a
real <input>, which is the signal to raise the system IME, and that lands over the
bottom of the screen — exactly where our keyboard is.

So a directory panel showed Google's keyboard instead of the one the widget ships:
split across a 1920x1080 screen, with mic, GIF, emoji, clipboard and a settings key
that opens Google's own UI on a kiosk. On the panel that turned this up, the system
keyboard WAS voice input — the only enabled IME was Google's voice IME, so touching
the search box opened a microphone, on a wall-mounted tenant directory.

When we draw a keyboard, the input now carries inputmode="none", so the platform
leaves its keyboard down. The buttons write input.value directly, so nothing about
typing changes. Browsers that don't know inputmode ignore it, which is the right
fallback — a desktop preview behaves exactly as before.

Gated on the flag, not applied to the markup: with show_onscreen_keyboard off there
is nothing to cover, and the platform keyboard is the only way left to type.

Verified by removing the line and watching the new guard fail. 1669/1669 pass.
2026-08-14 08:59:31 -05:00
screentinker 04a2ad99d1
Check what is IN a cached update, and add a way to throw it away (#274)
A panel on prod looped on an update it could never apply, reporting a download
failure that was not one.

The staged-APK cache is keyed by FILENAME, and the filename is built from the
version the SERVER advertised. Prod advertised 1.9.34 while still serving the
1.9.33 file, so the panel saved 1.9.33 as `ScreenTinker-1.9.34.apk`. On every
retry it found that file, verified the signature — which passed, same key — reused
it, and installed a no-op. The version never changed, so the update was offered
again. Fixing the server did not help: the poisoned file is reused before anything
is fetched. It took `adb rm` to break the loop.

Two changes:

CHECK THE VERSION INSIDE. A cached APK is reused only when the versionName in the
file matches the version being installed, and a fresh download is checked the same
way before install. A server serving stale bytes now fails with what actually
happened — "server served 1.9.33 but advertised 1.9.34 — the update on the server
is stale" — instead of a download error, and the bad file is deleted rather than
kept to poison the next attempt. That makes this class self-healing: the panel
recovers on its own once the server is fixed.

A WAY TO CLEAR IT. `clear_update_cache` deletes every staged APK across all three
staging directories, with a button on the device page next to Force Update. Gated
on `system.self_update` — a player that can update itself is one that can hold a
bad download. Only caches are deleted; they are re-fetched on demand.

The version check should make the button rarely necessary. It exists because it
would have turned tonight's hands-on ADB recovery into one click, and because a
panel already holding a bad file predates the fix and cannot benefit from it.

1668/1668 pass; Android unit tests and lint clean.
2026-08-14 08:37:05 -05:00
screentinker 243fc6688c
Release notes: publish the changelog entry, not the commit subjects (#273)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Cutting 1.9.34 produced a release page reading:

    ### Changes
    - chore(release): v1.9.34
    - Changelog: one 1.9.34 entry, and credit where it was missing

while the entry describing single sign-on, the removal of the last native image
dependency, three update failures and every outside contributor sat in
CHANGELOG.md and was never published. The notes on the release page are what most
people actually read; they should be the written ones.

The workflow now takes the section for the version being released and uses it as
the body. Commit subjects remain the fallback for a version with no entry, so a
release never publishes with no notes at all — scripts/bump-version.sh already
warns about a missing heading, and this is the same gap showing up downstream.

awk rather than sed for the extraction: the body contains regex metacharacters and
markdown that a sed range would mangle.

Verified against the real file: 1.9.34 extracts 269 lines and stops at the next
heading with all 13 contributor credits intact, 1.9.33 and 1.9.29 extract cleanly,
and a version with no entry yields nothing and takes the fallback.

v1.9.34's notes were corrected by hand after release; this is so the next one does
not need that.
2026-08-13 22:31:32 -05:00
ScreenTinker 741bc7b6a3 chore(release): v1.9.34 2026-08-13 22:10:07 -05:00
ScreenTinker 60dacad303 Changelog: one 1.9.34 entry, and credit where it was missing
Replaces fourteen 1.9.34-alphaN sections with a single release entry. Someone
asking "what is in 1.9.34?" wants one description of single sign-on, the native
dependency removal, the update fixes and the install statistics — not a fourteen-
step reconstruction of how it got there. The alpha history stays in git and on the
releases page.

Folded in rather than lost: contributor attribution, the warning that requiring SSO
clears passwords irreversibly, the widget-editor Preview exclusion and why it is
excluded, the dashboard layout fixes, and the known limitations that are still true.

Intra-alpha fixes are deliberately absent — a bug introduced in alpha4 and fixed in
alpha5 never reached anyone upgrading from 1.9.33, and listing it would describe
the sausage rather than the release.

Adds a Thanks section. Credit had been recorded inconsistently: of seventeen merged
pull requests from outside the project, two were acknowledged. @BlazzzPlay had
eight merged and none. @albanobattistella's Italian translation had none.
@bold-media-group has filed roughly fifty issues, including the OTA and content-
loading faults that drove several releases, and appeared nowhere. Rather than
rewrite years of published entries, everyone is acknowledged here in one place.
2026-08-13 22:08:16 -05:00
ScreenTinker 463e21f4d9 chore(release): v1.9.34-alpha14 2026-08-13 21:19:29 -05:00
ScreenTinker cb4b491840 Changelog for 1.9.34-alpha14 2026-08-13 21:19:28 -05:00
screentinker 23e80f2d27
Runbook: three traps from the Pi 5 report (#245) (#272)
The installer and launcher fixes shipped in #248 and are in the changelog, but the
operator-facing lessons were not written down anywhere, and two of them are general
rather than Pi-specific:

  - a piped installer cannot ask you anything, because the pipe IS stdin — the
    prompt gets EOF and takes the default while looking like a choice
  - X11 tools no-op silently on Wayland, so blanking suppression and cursor hiding
    can be entirely absent while every command in your notes appears to have worked

The third answers the question in the issue that the fix did not address: overlay
FS is fine for a player-only Pi (the cache re-downloads) and quietly destructive
for an all-in-one, where the database, WAL, uploads and thumbnails are written
continuously and a read-only root discards them at every reboot.
2026-08-13 21:10:53 -05:00
ScreenTinker d6c1a36d8c chore(release): v1.9.34-alpha13 2026-08-13 20:49:24 -05:00
ScreenTinker ab9ce40997 Changelog for 1.9.34-alpha13 2026-08-13 20:49:23 -05:00
screentinker 76f1fb7e7a
Stage the APK wherever the device will actually take it (#271)
A panel could not update, forever, while downloading content perfectly well. Its
content cache writes to INTERNAL storage and works; the APK path insisted on
EXTERNAL storage, which on that device is present but unwritable — so the download
died at outputStream(), before a single byte, roughly one second after the command.
The server logged the request as served, the client reported "failed to download or
failed signature verification", and nothing pointed at a directory.

apkDir() previously asked canWrite(), believed the answer, and returned external.
The alpha12 preflight then PROVED the directory with a real write and refused —
correctly diagnosing the problem and still not updating, because it had no way to
choose somewhere else. A fallback that only reports is not a fallback.

apkStagingDir() now walks candidates and returns the first that genuinely accepts
bytes:

    internal  /data/data/<pkg>/files/Download   — always mounted, always writable
    external  the old location                  — kept, it survives uninstall
    cache     /data/data/<pkg>/cache/Download
    files     the app's files dir, no subdirectory to create

Internal is first because it cannot fail: it is this app's own private directory,
and if it is unwritable the app is not running. External is a convenience — visible
for a manual install, survives uninstall — and it is the one that breaks, so it is
no longer the default. Every candidate is proven by writing a probe byte and
deleting it, never by asking canWrite(), which returns true on volumes that then
refuse the write. That is precisely how this hid.

If no candidate works, the failure names every one it tried and why, instead of the
first excuse.

The pushed-APK path uses the same cascade; it shared the fault and reported none of
it.

⚠️ This cannot reach a panel already stuck: the broken path is the delivery
mechanism, and Push APK shares it. Such a panel needs ONE manual install, after
which it is permanently self-healing.
2026-08-13 20:49:20 -05:00
ScreenTinker 66df64a798 chore(release): v1.9.34-alpha12 2026-08-13 20:20:21 -05:00
ScreenTinker 66b5a9cc9d Changelog for 1.9.34-alpha12 2026-08-13 20:20:20 -05:00
screentinker 26c059c1b8
Every build from alpha10 onward sorted below alpha8 (#270)
A plain string compare on the prerelease tag put "alpha11" below "alpha8", because
'1' < '8'. The OTA check therefore answered client-newer and refused to offer the
update — while reporting the newer build as `latest` in the same response:

  latest_version: 1.9.34-alpha11   current_version: 1.9.34-alpha8
  update_available: false          reason: "client-newer"

So a fleet on alpha8 or alpha9 could not be moved forward at all, silently, and
nothing about the symptom pointed at version ordering. alpha10 was never really on
offer either; the last update that genuinely worked was alpha6 -> alpha8, where the
lexical order happens to be right by luck.

This is what semver specifies for a single alphanumeric identifier, and it is
simply not what the naming means. lib/version-precedence.js compares digit runs
NUMERICALLY, so alpha8 < alpha9 < alpha10 < alpha11, while leaving everything else
alphabetical — beta still outranks alpha, rc still outranks beta, and a release
still outranks any prerelease of the same core.

Dot-separated identifiers are compared per semver and a shorter run loses, so
moving the naming to the semver-correct `-alpha.11` form later needs no further
change here.

TWO comparators carried the assumption, each with a comment asserting lexical was
fine "for our naming" — true only while the counter stayed below 10. Both now use
the shared helper rather than a third copy drifting into the same trap:
  - lib/ota-breaker.js      the Android OTA path
  - lib/brightsign-update.js  the BrightSign host package, where a wrong-way
    comparison replaces the script that boots the player

lib/ghcr-check.js was checked and is unaffected: it rejects prerelease strings
outright rather than ordering them.

Tests pin the exact stranding case end to end — decide('1.9.34-alpha8',
'1.9.34-alpha11') must be an offer, not client-newer — plus the reverse direction,
so a future change cannot merely invert it.

1668/1668 pass.
2026-08-13 20:20:17 -05:00
ScreenTinker 414c1e9ab5 Changelog for 1.9.34-alpha11
The bump was tagged before this landed — a quoting error swallowed the entry and
bump-version.sh's CHANGELOG guard is a warning, not a stop. The v1.9.34-alpha11
tarball therefore ships without it; the repo has it from here.
2026-08-13 19:58:43 -05:00
ScreenTinker 8b0601b7bc chore(release): v1.9.34-alpha11 2026-08-13 19:57:50 -05:00
screentinker feda25943c
OTA: say which failure happened, and stop refusing readable APKs on API 28/29 (#269)
* OTA: say which failure happened, and stop refusing readable APKs on API 28/29

Two changes, both aimed at the same dead end: a panel that will not update and a
message that cannot tell you why.

NAME THE FAILURE. "failed to download or failed signature verification" covers
SEVEN distinct branches — three of them download failures where verification never
runs at all. Every specific reason went to logcat, and an unprivileged app UID
cannot read logcat on Android 9, so in the field the message was unactionable: it
named a symptom shared by unrelated causes and pointed at the wrong half of the
code as often as the right one. Each branch now records what actually happened and
the operator sees it:

    "not installed — server returned HTTP 416 for the APK"
    "not installed — could not read signing certificates (archive=0, installed=1) on API 28"
    "not installed — APK is signed by a different key than the installed app"
    "not installed — download/install threw IOException: ENOSPC (downloaded 41232 bytes)"

The byte count rides along on verification failures so a truncated download is
distinguishable from a genuine key mismatch — the two look identical today.

FALLBACK ARCHIVE CERT READ. On API 28/29 the archive's signer comes from the
legacy GET_SIGNATURES path (#139: signingInfo is null for ARCHIVES below API 30).
When PackageManager returns nothing there, we refused a possibly-fine APK with no
way to tell that apart from a real mismatch. It now reads the v1 signature itself
via JarFile before giving up.

This does NOT weaken the check. JarFile with verify=true only populates
JarEntry.certificates after the covered bytes have been read and verified — the
read IS the verification — and the extracted cert is still compared against the
installed app's. An unsigned, tampered or differently-signed APK still fails, and
any error in the fallback returns empty, which still refuses the install.

Deliberately NOT done: disabling signature verification on Android 9. It would mean
those panels silently installing whatever the server hands them — on device-owner
hardware that is remote code execution, and the cheap Android 9 boxes are the ones
most likely on a customer's flat network. It also might not fix anything, since
three of the seven branches never reach verification.

Context: a panel on alpha failed four forced updates this evening while succeeding
at an unattended one, and four separate theories for it died on contact with
evidence. The reason this took an evening is that the device could not say what
went wrong. That is the actual bug being fixed here.

* OTA: prove the destination is writable before downloading

Every theory this evening turned on whether the app could actually put a file
somewhere, and nothing in the code ever checked. A returned directory path is not
the same as a usable one: it can be missing, unwritable, on a volume that has gone
away, or simply full — and all four surfaced as the same opaque "failed to
download" as a genuine network fault.

apkDir() now proves the external directory before choosing it (exists-or-created,
and canWrite) rather than trusting a non-null path, and falls back to internal
storage when it does not hold up.

apkDirProblem() runs BEFORE the network call on both download paths and names the
real condition:

    "cannot stage the update — no write permission on /storage/…/Download"
    "cannot stage the update — only 6MB free on /data/…/Download, need ~18MB"
    "cannot stage the update — write test failed in /storage/…: IOException EROFS"

It does not infer from canWrite(), which returns true on volumes that then refuse
the write; it writes a probe byte and deletes it. Free space is checked against
DOUBLE the APK, because the installer stages its own copy — a volume with exactly
the download's worth free still fails later, at install time, where the message is
even further from the cause.

The pushed-APK path gets the same preflight; it shared every one of these failure
modes and reported none of them.
2026-08-13 19:57:27 -05:00
ScreenTinker 0b10776701 chore(release): v1.9.34-alpha10 2026-08-13 19:00:53 -05:00
ScreenTinker 521025a073 Changelog for 1.9.34-alpha10 2026-08-13 19:00:52 -05:00
screentinker 58fdf8122c
Install statistics: send on opt-in, name a blocked firewall, and add an operator collector (#268)
Three changes, all about the same failure: sharing appears to be on while nothing
actually arrives.

SEND ON OPT-IN. Turning sharing on now reports immediately instead of waiting for
the next daily tick. Two reasons: the operator is standing right there, and
"nothing has been sent" for the next 24h reads as broken at exactly the moment
someone is checking whether it works. It also means an egress-filtered network
fails HERE, where we can name the host to unblock, rather than silently tonight
where nobody is watching.

NAME THE FAILURE. Failed attempts are now recorded separately from successes, so
Settings can say which address did not answer and why, instead of showing an empty
"nothing sent yet". A blocked outbound connection is the normal failure on a
self-hosted box and is otherwise completely invisible — the operator cannot tell a
firewall from a broken feature. A later success clears the complaint, so a stale
warning never outlives the problem it describes. Docs gained a section on it, and
the UI states plainly that nothing needs opening inbound.

OPERATOR COLLECTOR, ADDITIVE. TELEMETRY_EXTRA_ENDPOINT lets an operator post the
same three fields to their own collector.

The naming is the point. It replaces TELEMETRY_ENDPOINT, which was a true override
— and an override is the wrong shape here, because a variable called "endpoint"
that silently redirected the report someone agreed to SHARE would make the opt-in
mean something other than what the UI says. Our address is hard-wired and not
overridable; theirs is explicitly additional and named so it cannot be mistaken for
a replacement. Settings lists every destination a report goes to.

The operator collector is independent of the sharing switch, because it is their
server posting to their host and our opt-in has no business gating it. So an
operator who wants internal fleet numbers with nothing leaving for us sets it and
leaves sharing off — supported on purpose, and tested.

Destinations are attempted separately: one unreachable collector must not cost the
other its report.

1662/1662 pass. Tests pin the properties that matter: an operator collector never
replaces the shared report, sharing-off still sends nothing to us whatever else is
configured, one dead destination does not stop the other, and a failure records the
address actually tried.
2026-08-13 19:00:37 -05:00
ScreenTinker 13534d9b61 chore(release): v1.9.34-alpha9 2026-08-13 17:27:29 -05:00
ScreenTinker b906bfa65f Changelog for 1.9.34-alpha9 2026-08-13 17:27:28 -05:00
screentinker e9bd8ac8af
Opt-in install statistics (#267)
There is no way to answer "how many screens run ScreenTinker?". The product is
self-hostable by design, so most installs are invisible to us on purpose — and
should stay that way. This asks once, and reports only if the operator says yes.

The entire payload is three fields:

    { instance_id, version, screen_count }

instance_id is a random UUID minted on first use and kept in app_settings. It
carries nothing about the install; its only job is to let two reports from the
same server be recognised as one server, so a count is a count rather than a sum
of duplicates. That makes a report pseudonymous rather than anonymous, and the
wording shown to operators says so rather than claiming otherwise.

The payload is short on purpose. Every field added costs participation, and
participation is the only thing that makes the resulting number worth quoting.
Player-platform counts were considered and left out: release assets are already
published per platform, so GitHub's per-asset download counts answer "where should
effort go" at zero privacy cost and without asking anyone for anything.

Verifiability is the feature, not the copy. Settings shows the ACTUAL payload this
server would send, generated live from its own data, plus what it last really sent
and when. The payload is built in one function so a reviewer can check it at a
glance, and the test fails if a field is ever added.

Both answers persist. Declining is remembered as 'off' rather than falling back to
'unasked', so the prompt cannot return after an update — re-prompting is how
telemetry earns its reputation and gets patched out.

Collector side is inert unless TELEMETRY_COLLECTOR=1, so a normal install never
exposes the endpoint. Reports upsert on instance_id rather than appending, so an
install reporting daily occupies one row rather than 365 a year. The source IP is
never read or stored — receiving one is unavoidable, logging it would quietly turn
a pseudonymous report into an identifiable one.

Tests pin the negative promises, which are the ones that rot silently: sends
nothing before consent, sends nothing after a decline, payload is exactly three
keys, id survives a restart, a failed send never records a phantom report. Screen
count excludes unpaired provisioning rows, which would otherwise overstate the one
number this exists to state honestly.

docs/telemetry.md documents the payload, what is not sent, how to verify it, and
that any published total is a floor rather than a basis for extrapolation.

1657/1657 pass.
2026-08-13 17:27:10 -05:00
ScreenTinker 8b162ecce2 chore(release): v1.9.34-alpha8
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-13 14:32:54 -05:00
ScreenTinker 7934156a2e Changelog for 1.9.34-alpha8 2026-08-13 14:32:53 -05:00
screentinker 3234c923a3
An APK download with no external storage went nowhere, silently (#266)
getExternalFilesDir() returns null whenever external storage is unavailable, and
on a signage panel that is not exotic: no emulated volume, a vendor ROM that never
mounts one, an ejected card, storage still unmounted early in boot.

Both APK download paths did:

    File(context.getExternalFilesDir(DIRECTORY_DOWNLOADS), name)

Java's File(File, String) treats a null parent as "no parent" and silently yields
a RELATIVE path, so the download targeted `ScreenTinker-x.y.z.apk` in the process
working directory — `/` — which is not writable. The write threw, the generic
catch swallowed it, and the caller reported only "failed to download or failed
signature verification".

That message is why this was expensive to find. The HTTP request SUCCEEDS (the
server logs a served download at the exact moment of each failure), so it does not
look like a download problem; the signing key is fine, so it does not look like a
verification problem; and because nothing is ever written there is no partial file
to find. It also never recovers — every attempt fails the same way, forever.

installFromUrl (the dashboard "Push an APK" button) carried the identical line, so
the obvious workaround for a panel in this state was broken by the same bug.

Both now use apkDir(), which falls back to internal storage. filesDir cannot be
unmounted: if it is gone the app is not running.

file_paths.xml gains a <files-path> for the same directory. The silent
PackageInstaller path streams the file itself and needs nothing there, but the
intent-based install FALLBACK resolves it through FileProvider and would throw
"Failed to find configured root" — turning an already-degraded panel into one that
cannot install at all.

⚠️ This cannot reach an affected panel over the air: the broken download path IS
the delivery mechanism, and Push APK shares the bug. A panel already in this state
needs one manual install to escape it.

Root cause is inferred from converging evidence on an Android 9 panel (HTTP served
at each failure, no APK anywhere on the device, the app's own external files dir
denied to its own UID, both paths failing identically, signing verified at parity
against the release it already installed). Handling a null return is correct
regardless — it must never become a relative path.
2026-08-13 14:32:34 -05:00
ScreenTinker 1fc50ec263 chore(release): v1.9.34-alpha7 2026-08-13 13:41:46 -05:00
ScreenTinker cb9f6a84df Changelog for 1.9.34-alpha7
Covers everything since alpha6: the stage-sizing fix (#262), the operations
runbook (#261), the sharp removal (#263) and the better-sqlite3 pin (#264/#265),
plus the dependency-reinstall requirement that applies in both directions.
2026-08-13 13:41:45 -05:00
screentinker 128a5be1b1
Node 22 preparation: upgrade runbook, changelog notes, and the one test that breaks (#265)
* Document the Node.js upgrade procedure and this build's reinstall requirement

Upgrading the runtime does not go through scripts/upgrade.sh, so nothing
reinstalls dependencies — which is precisely when the one remaining native
module goes stale. The runbook now covers the version floor imposed by
--env-file-if-exists, why the better-sqlite3 pin is exact, and why a version
without a matching prebuild can turn Restart=always into a boot loop.

Also records that this build changes dependencies in both directions: rolling
back past it needs the reinstall too, because earlier builds import sharp at
runtime and this one drops it from production dependencies.

Kept deployment-neutral — no hostnames, addresses, or environment specifics.

* Fix the only test that fails on Node 22

Node 22 added a built-in `navigator` global, defined as a getter with no
setter. The test's shim assigned to it, which throws "only a getter" under
'use strict' on 22 while being a normal assignment on Node 20, where the global
does not exist at all. It is configurable, so define it instead of assigning.

Defining it unconditionally is also the better fixture: Node 22's own navigator
reports the HOST locale, so a test reading its language would otherwise depend
on the machine or CI runner it happens to run on.

This was the single failure in an otherwise clean Node 22 run (1639/1640 with
better-sqlite3 12.9.0), and it is confined to test code — no production server
or frontend file assigns to globalThis.navigator.

1649/1649 on Node 20.
2026-08-13 12:37:36 -05:00
screentinker c436a44c89
Pin better-sqlite3 to 12.9.0 (was ^9.4.3) (#264)
Prepares for the Node 22 move by decoupling it from the database driver, so the
two upgrades land as independently reversible steps rather than one flag day.

9.6.0 cannot work on Node 22. It uses the raw V8 API (220 v8:: references, zero
napi_), so it is ABI-locked per Node major, and its GitHub release assets carry
prebuilds for ABI 108/115/120 only — nothing for Node 22's 127. Its install script
is `prebuild-install || node-gyp rebuild --release`, so on Node 22 it silently
falls through to compiling raw-V8 code against Node 22 headers. That is not just
slow: lib/preflight-deps.js rebuilds synchronously before the server listens, and
prod's systemd unit is TimeoutStartSec=90 with Restart=always, so a slow or failing
compile is an unbootable loop rather than the intended self-heal.

12.9.0 ships prebuilds for BOTH Node 20 (ABI 115) and Node 22 (127), so neither the
current runtime nor the target has to compile anything.

THE PIN IS EXACT ON PURPOSE — ^12.9.0 would defeat it. 12.10.0 dropped the Node 20
prebuild while still advertising "20.x" in engines, so a caret resolves to 12.11.x
and reintroduces the from-source compile on today's runtime. Verified against the
release assets per version:
    12.0.0 / 12.2.0 / 12.4.5 / 12.6.2 / 12.9.0   ABIs 115,127,...
    12.10.0 / 12.10.1 / 12.11.1                  ABIs 127,137,141,147 — no 115
The reasoning is recorded in preflight-deps.js, which is where anyone hitting the
matching failure will already be reading.

13.x was considered and rejected FOR NOW: it is the first N-API release, which ends
the per-major ABI problem for good (8 prebuilds keyed by platform, not ABI) and is
where we should eventually land — but engines is ">=22", so it cannot be adopted
while prod, alpha and CI all run Node 20. It is also three weeks old with three
patch releases, which is young for the one component that owns all the data.

No API changes to absorb: every major from 10 to 13 bumped only for dropping EOL
Node/Electron versions, so the ~1486 .prepare(), 46 .transaction() and 59 .pragma()
call sites are untouched.

Verified on Node 20: installed from a PREBUILT binary (no obj.target, so no
compilation), opens a real database, and the WAL path the #149 checkpointer depends
on still works — journal_mode=WAL engages on a file DB, pragma(...,{simple:false})
returns the expected shape, and a second connection from another handle reads and
runs wal_checkpoint(TRUNCATE). 1649/1649 tests pass.
2026-08-13 12:18:47 -05:00
screentinker 13c9c67335
Drop sharp: pure-JS image ops on a worker thread (#263)
* spike: replace sharp with pure-JS image ops (jimp + jsquash WASM)

Removes the last native dependency from the ingest path, so the server no longer
needs a per-platform/per-ABI prebuilt to thumbnail an image. Motivated by getting
the server onto hardware with no toolchain, but the ABI tax is paid on every
install — it is the same failure class lib/preflight-deps.js exists to explain.

lib/image-ops.js is the whole surface: metadata() and writeThumbnail(), which are
the only two things ingest ever asked sharp for.

Format parity holds. jpeg/png/gif/tiff/bmp are native to Jimp; webp and avif go
through @jsquash WASM, whose bundled .wasm must be compiled by hand because the
packages locate it with fetch(file://) and Node has no file:// fetch — the only
symptom otherwise is a bare "fetch failed". heic is unsupported, as it already
was: sharp advertises heif but its prebuilt libvips refuses HEVC.

#170 is preserved by a different mechanism. Jimp applies EXIF orientation at
decode and rewrites the tag to 1, so metadata() reports display dimensions and
imageDisplayDims() runs as a no-op instead of swapping W/H a second time. The
helper stays in the path so the rule keeps living in one place.

Verified: 1643/1643 tests pass, and ingest was exercised in a child process with
node_modules/sharp moved aside — jpeg, EXIF-rotated jpeg, png, webp, avif, gif
all measured and thumbnailed correctly, corrupt input still yields nulls with no
phantom thumbnail_path.

KNOWN BLOCKER, do not ship as-is: Jimp is pure JS on the main thread, where sharp
handed work to a libvips threadpool. A 12MP photo goes 65ms -> 1079ms, and the
event loop stalls for 1003ms of it (sharp: zero stalls). thumbnail-backfill.js
walks a whole library at boot, so this reproduces #240 exactly — blocked loop,
missed heartbeats, panels marked offline, reconnect churn. Needs a worker_thread
offload before this is viable; image-ops.js is the seam for it.

* Run image decoding on a worker thread

Fixes the blocker the previous commit shipped with. Pure-JS decoding costs ~1s of
solid CPU for a 12MP photo, and in-process that is not a slow upload but a stalled
event loop — no heartbeats, no socket traffic. thumbnail-backfill.js walks a whole
library at boot, so it reproduced #240 (blocked loop -> missed heartbeats -> panels
offline -> reconnect churn) from our own maintenance. sharp never did this because
libvips works on a threadpool.

image-ops.js is now a dispatcher over image-ops-worker.js; the work moved unchanged
to image-ops-core.js, so callers and their failure contract are untouched.

Measured on a 12MP photo: 1079ms wall with the loop stalled 1003ms, to 1881ms wall
for two ops with ZERO stalls and 185 timer ticks serviced. Wall time is worse and
that is fine — it is off the main thread now.

Design notes, all load-bearing:
  - ONE JOB AT A TIME. A decoded 12MP bitmap is ~48MB of RGBA; overlapping jobs
    multiply peak memory by queue depth, which is the wrong failure on the small
    targets this change exists to reach. Costs no throughput — the work is CPU-bound
    and one busy worker already saturates its core.
  - unref'd while idle, ref'd only in flight. Otherwise scripts/backfill-rotation-
    dims.js never exits and `node --test` hangs forever. Verified: a CLI-style run
    exits in 104ms, code 0.
  - decode failures reply as messages, so one bad upload cannot tear down the worker
    and take unrelated queued jobs with it.
  - in-process fallback if a thread cannot be had, warned rather than silent.

test/image-ops.test.js pins the loop-liveness property, which no functional test
would catch. Its thresholds were mutation-tested against the inline path: the first
version passed there too (4MP stalls only ~355ms, under a non-flaky threshold), so
the fixture is 12MP and the thresholds sit in the gap between the two behaviours —
worker ~90 ticks/~0ms, inline ~3 ticks/~897ms. It now fails inline, as a guard must.

1647/1647 pass. Ingest re-verified with node_modules/sharp moved aside.

* Measure and thumbnail an image from a single decode

Ingest asked for metadata() then writeThumbnail(), which decoded the file twice.
That pairing was free under sharp, whose .metadata() only parses the header, but
every decode here is a full one — ~1s for a 12MP photo — so the naive translation
doubled the most expensive thing on the ingest path.

image-ops.measureAndThumbnail() returns both from one decode. Full ingest of a
12MP photo: 2 decodes/~1.9s -> 1150ms, still with zero event-loop stalls.

The subtlety is the failure contract. In the two-call version width and height
were assigned BEFORE the thumbnail was attempted, so a failed thumbnail still left
usable dimensions on the row — the player needs them to letterbox. Merging naively
would have turned any thumbnail failure into total metadata loss. So a WRITE
failure is reported ({thumbnailWritten:false, thumbnailError}) with the dimensions
intact, and the caller sets thumbnail_path only when the write succeeded, keeping
the phantom-path discipline. A DECODE failure still throws — there is nothing to
report about an unreadable image.

backfill-rotation-dims.js deliberately keeps the separate calls: it probes every
image row but regenerates a thumbnail only for the few whose dimensions changed,
so pairing them there would decode files it has no reason to thumbnail.

Tests count decodes rather than timing them — an exact property, and a wall-clock
comparison would be flaky under load. The count filters for reads of the file under
test: Node's ESM loader also goes through fs.promises.readFile, so a raw call count
picks up jimp's and the WASM codecs' lazy loading and reads 30 instead of 1.

1649/1649 pass. Ingest re-verified across all 7 formats with sharp moved aside.

* Dockerfile: sharp is no longer a production dependency

--omit=dev now leaves it out entirely; better-sqlite3 is the only native module
the builder stage still needs a toolchain for.
2026-08-13 11:40:13 -05:00
screentinker aa09631d69
Merge pull request #262 from screentinker/fix/stage-sized-to-stale-window
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
The stage kept the size of a window that no longer existed
2026-08-13 00:09:14 -05:00
ScreenTinker 2ed87f5cfb The stage kept the size of a window that no longer existed
A display could come up with a border on one edge the exact size of a system
bar that was set to hide -- sometimes. Reboot and it might fill the screen
correctly. It has been "sometimes doesn't fill" for a long time, across
devices, which is what a race looks like from the outside.

applyOrientation() read `resources.displayMetrics`, which reports the app
WINDOW rather than the panel. Immersive mode is a request: the bars hide and
the window grows several frames later. A playlist arriving before that finished
measured a bar-sized window and wrote it straight into rootView's layoutParams
-- and it stayed there, because the guard compared only the orientation STRING,
which never changes on a display that has always been landscape. The window
then expanded and the stage did not, leaving dead space the exact size of a bar
that was no longer on screen. onWindowFocusChanged re-asserted the immersive
flags but never re-measured, so nothing repaired it.

Rendering the cached playlist immediately at boot made losing that race the
common case rather than a rare one.

Three changes, each of which alone would help and which together make it
unwinnable:

  - re-measure on onWindowFocusChanged, so a stage sized during any transient
    window state corrects itself instead of being permanent;
  - the guard compares the measured SIZE as well as the orientation, so
    "landscape -> landscape" can repair a bad measurement;
  - ask for full-bleed through WindowCompat/WindowInsetsControllerCompat as
    well as the deprecated systemUiVisibility flags, because some OEM builds
    honour only the modern route.

Deliberately measures the WINDOW, not the display. On one RK356x box
`dumpsys window` reports `init=1920x1080 app=1920x1024`: the firmware reserves
56px for a hidden bar, and those pixels are not the app's to paint. Sizing the
stage to the display there would not fill the gap, it would push the bottom of
every asset outside the window and crop it silently -- worse than a border. If
`app=` still differs from `init=` after this, the reservation is firmware
behaviour and has to be turned off on the device.

Also shipped as a 1.9.24-based build for the reporting customer, so the change
could be tested in isolation against ten releases of drift.
2026-08-13 00:03:03 -05:00
screentinker 717d192d5f
Merge pull request #261 from screentinker/docs/operations-runbook
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Write down how to run one, not just how to install one
2026-08-12 21:19:12 -05:00
ScreenTinker d4d95b6b92 Write down how to run one, not just how to install one
The README covers installing, upgrading, backing up and admin recovery -- the
happy paths. It says nothing about the parts that actually go wrong: which
deployment shape you are on and why the other shape's commands silently do
nothing, how to tell a deploy really took when a version string cannot prove it,
and the handful of traps that have each cost real time at least once.

docs/operations.md is that runbook. The load-bearing entries:

  - the served APK is a bind-mounted FILE, so it must be replaced in place. mv
    or cp gives the host a new inode while the container keeps serving the old
    bytes, with nothing in any log to say so;
  - the advertised apk_size must equal the served bytes or displays download,
    reject and retry forever -- and the OTA query parameter is `version`, where
    the wrong name produces a result that looks identical to a broken OTA;
  - a version string does not prove new code is running, and neither does the
    build hash: it covers the frontend, so a server-only change deploys with an
    unchanged hash and looks exactly like a stale image;
  - ownership before checkout, because a partial checkout leaves VERSION updated
    while the code is the old release and no migrations ran;
  - a service user with no home directory makes npm install nothing while
    appearing to succeed;
  - a prerelease sorts below its own release, and the Android update check
    offers one to any older client on the stable channel;
  - native modules are built for one Node ABI, and the mismatch presents as
    hundreds of unrelated test failures rather than one clear error.

Deliberately generic: no addresses, hostnames, customer names or credentials, so
it is useful to anyone self-hosting rather than a description of one estate.
Every claim was checked against the code or the workflows rather than recalled.
2026-08-12 21:14:33 -05:00
ScreenTinker 72fd2314b5 chore(release): v1.9.34-alpha6 2026-08-12 15:15:43 -05:00
ScreenTinker f796876d91 CHANGELOG: 1.9.34-alpha6 2026-08-12 15:15:42 -05:00
screentinker b28924012d
Merge pull request #260 from screentinker/docs/sso-setup-guide
Document how to actually set single sign-on up
2026-08-12 15:15:15 -05:00
ScreenTinker 4bc0d433b0 Document how to actually set single sign-on up
The README described what SSO is and which variables exist. It did not say where
to click, which of the several plausible values to use, or what any failure
means -- so configuring it meant reading source, and every wrong turn produced an
error code with no stated cause.

docs/sso-setup.md walks both audiences: the operator wiring up Google or
Microsoft for the instance, and an organization admin bringing their own
provider and proving a domain. Written from doing it end to end against real
Google and Entra tenants, so the traps in it are the ones actually hit rather
than the ones imagined:

  - MICROSOFT_TENANT_ID is the directory that AUTHENTICATES the user, not the
    one the app registration lives in. For personal accounts those differ, and
    using the visible Directory (tenant) ID fails every login with an error that
    points at the tenant rather than at the setting;
  - Web platform, not SPA -- a SPA registration is refused at the token endpoint
    because the exchange is server-side and sends no Origin;
  - a Web registration is a confidential client, so the secret is not optional;
  - Entra needs the `email` optional claim added, or the token arrives with no
    address and fails as no_email;
  - Google's redirect URI matches byte for byte, and Testing publishing status
    silently limits sign-in to listed test users.

Every error code the server can emit is in a table with its usual cause. Each
one was checked against the source rather than remembered, as were the variable
names and the DNS record format.

Also covers what the account rules mean in practice: linking deletes the
password, unlinking sets a new one in the same step, SSO-only clears passwords
irreversibly, and linking the platform admin makes that provider the only way
in.
2026-08-12 15:11:13 -05:00
ScreenTinker 350ca58f22 chore(release): v1.9.34-alpha5 2026-08-12 14:49:13 -05:00
ScreenTinker dd18a795ee CHANGELOG: 1.9.34-alpha5 2026-08-12 14:49:11 -05:00
screentinker b0de7ee208
Merge pull request #259 from screentinker/fix/sso-link-start-bearer-token
Link start cannot be navigated to: a bearer token does not survive it
2026-08-12 14:49:08 -05:00
ScreenTinker 3617a1a116 Link start cannot be navigated to: a bearer token does not survive it
"Authentication required" on every click of Link. The Settings button did
`location.href = /api/auth/oidc/<slug>/link/start`, which is a top-level
navigation -- and this app's session lives in localStorage and travels as an
Authorization header, so the request arrived anonymous and requireAuth refused
it, correctly.

The login /start route works precisely because it needs no session. Copying its
shape for a route that does need one was the mistake.

The client now FETCHES link start with its token and navigates to the URL it
returns. The transaction cookie is still set by that response, because a
same-origin fetch stores Set-Cookie normally, so the callback is unchanged.
beginOidc grew an asJson flag rather than a second copy of the PKCE/state/nonce
setup, so login and link still cannot drift apart.

Both mutations fail the new test: navigating straight at the route, and having
the server redirect instead of answering with JSON.
2026-08-12 14:44:23 -05:00
ScreenTinker ffceaf2c1f chore(release): v1.9.34-alpha4
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-12 11:53:35 -05:00
ScreenTinker a5cde06ed7 CHANGELOG: 1.9.34-alpha4 2026-08-12 11:53:34 -05:00
screentinker bc95f58d66
Merge pull request #258 from screentinker/feat/account-linking-and-identifier-first
Let an existing account move to SSO, and ask who you are before how
2026-08-12 11:53:04 -05:00
ScreenTinker 184ff71dee Let an existing account move to SSO, and ask who you are before how
Two halves of the same problem: an account created with a password could never
use single sign-on, and the login page offered a credential before it knew
which one applied.

LINKING. Signing in with a provider never adopts an account that already has a
password -- that is the takeover the login path exists to refuse. The README
promised the way out ("the owner signs in locally and links from Settings") but
nothing had ever been built, so the refusal was a dead end rather than a
redirection. Settings now has a Sign-in method block: an account with a password
can link an instance-wide provider, and one on a provider can unlink back to a
password.

The account being linked comes from the SIGNED TRANSACTION -- the session that
started it -- never from the email in the returned token. That distinction is
the whole feature: taking it from the token would be the same email-keyed
takeover under a friendlier name. The email must still match the account's own,
because login resolves accounts by the asserted address, and one provider
subject may not be linked to two accounts.

Linking DELETES the password rather than keeping it alongside. One credential at
a time, and the confirmation says so in those words, because a password left
behind is a second way in that the user believes they replaced. Unlink therefore
takes the new password up front and writes it in the SAME statement as the
unlink -- never unlink now and set a password after, which leaves an account
briefly, or on failure permanently, with no way in.

Instance-wide providers only. An organization's provider is chosen by a
customer; letting one attach itself to a platform account would hand that
customer whatever the account can do.

IDENTIFIER-FIRST. The password box now appears only after an address has been
submitted, which is what lets the organization lookup happen before a credential
is offered: someone whose company requires its own provider is shown that,
rather than a password box that will be refused. Editing the address returns to
the identifier step so a corrected domain gets a fresh answer.

The per-keystroke lookup is gone with it. It answered for half-typed domains,
changed the form under someone mid-address, and spent a 10/min per-IP budget on
people who had not finished typing -- an office behind one address could exhaust
it without a single sign-in attempt.

Instance-wide providers stay visible at all times now, by decision: the server
refuses them for an SSO-only organization anyway, and hiding them made the page
change shape while typing.

Verified in a real browser, not only by rendering: password hidden -> submit ->
visible and focused -> edit the address -> hidden again, with no page errors.
Four mutations of the linking rules fail the tests (account from the email
instead of the session, keeping the password, allowing org providers, dropping
requireAuth).
2026-08-12 11:48:11 -05:00
ScreenTinker 6cd697c3fc chore(release): v1.9.34-alpha3
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-11 23:23:31 -05:00
ScreenTinker 1dc63ce84c CHANGELOG: 1.9.34-alpha3 2026-08-11 23:23:30 -05:00
screentinker bd0b39168f
Merge pull request #257 from screentinker/fix/org-sso-entra-email-verified
Customer Entra tenants: verify the domain, then be believed
2026-08-11 23:22:25 -05:00
ScreenTinker e5e5b75b85 Customer Entra tenants: verify the domain, then be believed
The previous fix let the instance-wide Microsoft button work and left the
customer-facing path broken, which is the worst way round. An organization that
brings its own Entra tenant would publish the TXT record, watch its domain go
green, and still be refused at login with `email_unverified` -- because Entra
sends no such claim and rowToProvider pinned the assumption off for every org
provider.

Requiring a claim Microsoft does not emit is not a security control, it is an
outage. What makes it safe to stop requiring it is the proof that already gates
these providers: the callback confines an org provider to its DNS-verified
domains, and an address only reaches the check after passing that. Whoever
controls a domain's DNS controls its mail, which is the same trust that makes a
verification link meaningful.

So the assumption is DERIVED from proof -- `verified.length > 0` -- rather than
pinned off. A provider that has verified nothing still assumes nothing, which is
belt and braces: emailAllowedForProvider already refuses it, since an empty
allow-list matches no domain, but deriving it here means a future reordering of
those checks cannot silently widen it.

It is never a column, and there is no column for it to be read from. An
organization must not be able to switch this on for itself; it is a consequence
of DNS proof, not a setting. A test asserts both -- that the value is derived
next to `source: 'org'`, and that no `assume_email_verified` exists in the
schema.

Domain confinement is untouched. An explicit `email_verified: false` is still
refused from anyone.

Mutations all fail the tests: assuming unconditionally, never assuming, and
reading it from the row.
2026-08-11 23:05:25 -05:00
ScreenTinker 77d41ae73e chore(release): v1.9.34-alpha2 2026-08-11 22:34:53 -05:00
ScreenTinker 38d72afd3f CHANGELOG: 1.9.34-alpha2 2026-08-11 22:34:52 -05:00
screentinker c15edc1bb8
Merge pull request #256 from screentinker/fix/microsoft-sso-email-verified
Microsoft sign-in could never complete: Entra omits email_verified
2026-08-11 22:34:29 -05:00
ScreenTinker b1a58144bb Microsoft sign-in could never complete: Entra omits email_verified
The OIDC callback required `claims.email_verified === true`. Entra ID v2 does
not send that claim at all, so every Microsoft login authenticated correctly
against the tenant and was then refused with `email_unverified` on the way back.
Nothing caught it: the SSO tests assert how the Microsoft issuer string is built
but never put a Microsoft-shaped token through the policy.

The strict check was itself a fix -- `=== false` had been accepting an omitted
claim -- and it is right for a provider a CUSTOMER configured, since such a
provider is chosen by the party it vouches for and its bare assertion is worth
nothing. What was wrong is treating that as a question about the token when it
is a question about who we trusted. `users.email_verified` is our own state; the
claim is the IdP's. An instance-wide provider was chosen by the operator -- the
same trust that already exempts it from domain confinement -- and Microsoft is
additionally pinned to one tenant GUID, so only that directory can issue a token
whose `iss` matches.

So the policy now depends on the provider, in emailIsVerified(), next to the
flag it reads so the two cannot drift:

  - explicit true            -> believed, from anyone
  - claim absent, operator   -> believed (Microsoft; opt-in for other IdPs)
  - claim absent, org        -> refused
  - explicit false           -> refused, always

Org providers pin the flag false in rowToProvider and never read it from the
row, so the takeover path the strict check existed to close stays closed.
Google is left strict: it does send the claim.

Also documents MICROSOFT_CLIENT_SECRET (supported in code, missing from the
table), that the redirect URI must be registered under Web rather than SPA, and
the email optional claim -- the other two ways an Entra setup fails.

All three mutations of this policy fail the new tests: reinstating the strict
check (3 failures), letting an org provider assume (1), and accepting an
explicit false (2).
2026-08-11 22:29:53 -05:00
ScreenTinker 6830fe58ea chore(release): v1.9.34-alpha1
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-11 21:54:55 -05:00
screentinker 78a403d35c
Merge pull request #255 from screentinker/fix/widget-preview-stays-isolated
Keep the widget editor's Preview isolated, whatever the org setting says
2026-08-11 15:58:47 -05:00
ScreenTinker 226c96c17e Keep the widget editor's Preview isolated, whatever the org setting says
#254 lets an organization opt out of widget iframe isolation so that players
can embed origin-strict third-party sites. It applied that opt-out to the
widget editor's Preview as well.

Preview is framed by the dashboard, from the dashboard's own origin, and the
dashboard keeps its session JWT in localStorage. So with the setting on, anyone
who can author a widget -- workspace_editor and up; viewers are refused at the
create route -- could put script in a text widget and read the session of
whichever admin clicked Preview. That is an editor -> admin escalation, and it
is not the risk the confirmation modal asks the admin to accept: a player runs
on a kiosk with a device token, an admin's dashboard session is a different
thing entirely.

The org setting is what makes players able to embed those sites, so the
/render path keeps consulting it. Preview is pinned to allow-scripts in both
places that build it -- the dashboard iframe and the server-side render -- so
neither a frontend change nor a new server caller can re-grant it alone.

Also correct the modal copy, which claimed same-origin would expose the session
of anyone viewing "a display or preview". Preview is now excluded, and the
display case is really the device token, so say that instead.

widget-preview-stays-isolated.test.js fails if either half is reverted; both
mutations were checked to fail before committing.
2026-08-11 15:53:57 -05:00
screentinker 6aeb703efe
Merge pull request #254 from ChrisChrome/main
Add org-level widget sandbox toggle.
2026-08-11 15:45:39 -05:00
ScreenTinker bddb78371f Merge fix/frontend-xss-sinks: escape user-controlled data at HTML sinks
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-11 11:44:10 -05:00
ScreenTinker ec450929ce Escape user-controlled data at the HTML sinks it actually reaches
A QA sweep found unescaped interpolations outside the SSO work. Auditing them properly
turned up 34 genuine HTML sinks; 23 carry data a user, a device or an identity provider
controls, and those are escaped here.

The ones that mattered:

  - app.js renders `user.name` in the shell on EVERY page, and an identity provider's
    `name` claim is stored verbatim, so an IdP could script the whole dashboard
  - designer element `label`/`location` and widget `location`/`query` land inside
    value="" attributes, where a single quote breaks out
  - content `folder` lands in a data-folder="" attribute
  - device `name` is set by the operator OR reported by the panel itself
  - workspace-members renders a SERVER error string through t(), which interpolates raw

⚠️ My first attempt was a codemod over everything my scanner flagged, and it was wrong.
It wrapped `progressText.textContent`, `block.title` and `confirm(...)` — none of which
are HTML, so escaping there shows users literal `&lt;`. Worse, it wrapped
`title: ev.title ? ... : null`, an API PAYLOAD, which would have written escaped markup
into the database. I reverted the whole thing and narrowed to interpolations that are
genuinely inside an HTML template, then read all 34 and chose 23.

Skipped deliberately: static app strings, i18n output, ternaries yielding `selected`,
`window.location.origin`, and sites already escaped.

Verified in Chrome, not by inspection: the payload was seeded into user.name,
device.name, content.filename/folder, widget.name/config and video_wall.name (the first
attempt's seeds silently failed on column names — the API responses are checked now),
then eleven views were loaded. Zero executions, zero live img tags — AND the payload is
visible as inert text in 6/6 views, which is what proves the views rendered it rather
than the test proving nothing.

1609 tests; every frontend module parses as an ES module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 11:44:10 -05:00
ScreenTinker 8361392ebd Merge feat/oidc-sso: OpenID Connect SSO, per-organization providers, DNS-verified domains
Replaces an OAuth implementation that verified nothing that mattered. The Google path
asked tokeninfo whether an ACCESS token was valid and trusted the email in the reply;
the Microsoft path handed a bearer token to Graph /me and trusted that. Neither checked
who the token was issued FOR, so any site a user signed into that asked for `email` or
`User.Read` could replay that token here and be issued a session as them. Identity now
comes from an ID token: signature against the published JWKS, iss, aud, azp, exp, and a
nonce this server generated for that specific login.

  - one flow for every provider (Authorization Code + PKCE, server-side), so Google and
    Microsoft are ordinary entries rather than special cases; any OIDC provider works
  - per-organization providers configured by customers, with sign-in domains PROVED by
    a DNS TXT record — a claim reserves nothing until DNS says so, lapses after 8 hours
    if unproved, and releases rather than renewing
  - optional per-organization SSO-only, where removing the requirement needs a platform
    admin's approval; the operator queue lives under Admin
  - a boot-time dependency preflight, because this branch removes a dependency and a
    rollback would otherwise not start

Instance-wide configuration is the default and unchanged: with no SSO variables set,
the login page and every auth flow behave exactly as before.

Six review rounds, sixteen agent audits. Roughly half of all defects found were in
FIXES rather than in original code — including an account takeover, three separate
lockouts, a CSP block that meant per-organization SSO had never worked in a browser at
all, and a stored XSS where the first fix escaped one of two copies of the same table.
Each is documented at the code it touches, because the reasoning is the part worth
keeping.

1609 tests.
2026-08-11 11:29:30 -05:00
ScreenTinker fbf55f842c Close the third QA round: limiter bypass, stored XSS, break-glass, org placement
Four HIGH findings. Two were mine, and one was a composition of two of my own fixes.

ONE EXTRA SLASH DEFEATED EVERY /api/auth LIMITER

`/api/auth//login` still reaches the login handler — Express normalises the mount
boundary for the router — but `app.use('/api/auth/login', rateLimit(...))` does not
match it, so the limiter never runs. A review got a real session after 60 unthrottled
password attempts. Same for //totp/verify (unlimited 6-digit brute force),
//forgot-password (unlimited reset mail to any address) and //sso/discover (the
customer-enumeration cap, gone). Fixing the limiter KEY could never help, because the
middleware was never invoked: the path is now collapsed to one canonical form before
routing. Pre-existing, and it falsified this file's own warning about walking past the
login limiter.

STORED XSS: I ESCAPED ONE COPY OF THE TABLE

My earlier fix patched views/admin.js line 357 and missed line 372 in the same
function — and missed views/settings.js entirely, which renders a SECOND copy of the
platform users table from the same endpoint, including the email in a raw text node.
The write path was `POST /api/admin/users`, whose EMAIL_RE barred only whitespace, so
an org or workspace admin (not a platform admin) could choose an address that executed
in the operator's session. Both tables escaped, both regexes tightened to reject markup
characters, verified against 11 address shapes.

I KILLED THE BREAK-GLASS WHILE CLOSING AN ORACLE

Hoisting the domain check above the account lookup — my fix for the enumeration oracle
— made `user.role !== 'platform_admin'` unreachable for enforced domains. On a
self-host the operator IS the org owner, and my would_lock_out_actor guard GUARANTEES
their address is inside the enforced set, so the recovery loop closed on itself:
approving a removal request needs a signed-in platform admin. Both properties hold now
by letting the operator through on a CORRECT PASSWORD only — every wrong answer is the
identical 403 whether the address exists, does not exist, or is theirs. Verified: 200 /
403 / 403 / 403.

Also fixed: enabling SSO-only locked out every password-holding member including the
admin who pressed the button (password refused by policy, SSO refused by
account_exists_local). An org provider now adopts a password account at a domain it has
PROVED by DNS when the org requires SSO — which is what a verified domain means, and
what every hosted identity product does.

SSO USERS WERE LANDING IN A PERSONAL ORG

The membership write added organization_members but no workspace_members, and
ensureDefaultOrgForUser looks at workspaces — so it minted each SSO user a private
organization and made it their current one. The customer's Members page read
"Members (1)" while their staff signed in successfully and were invisible.

ALSO: bcrypt on a NULL password_hash 500'd with a stack (and was an oracle for accounts
a provider deletion had returned to local); stranded_members was returned by the server
and discarded by the UI; a provider with zero domains was the one useless state with no
warning; two limiter shapes were missing (removal-request shared the garbage bucket —
an unauthenticated flood could deny the SSO break-glass path); doubled mail subject
prefixes; a DELETE that toasted "Saved"; a decided request left in the DOM with live
listeners; and a confirm dialog promising "immediately" when sessions already open
survive.

1609 tests, three clean runs. Limiter, break-glass, oracle parity and null-password all
verified against a running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 11:25:11 -05:00
ScreenTinker 94e1273ecd Fix: per-organization SSO was blocked by our own CSP and had never worked in a browser
THE HEADLINE FEATURE COULD NOT RUN.

"Continue with single sign-on" was a <form method="POST"> that redirected on to the
customer's identity provider. Chrome applies `form-action` across the WHOLE redirect
chain, and the dashboard sets `form-action 'self'`, so the hop to the provider was
aborted — silently. The user clicked and nothing happened: no navigation, no toast, no
spinner, a byte-identical page. Combined with SSO-only it was a total lockout: password
login answers 403 "use the single sign-on button", pointing at a button that cannot
work.

Every test I ran on this feature checked the button RENDERED. None clicked it.

The provider origins cannot be allowlisted — customers supply them at runtime. So the
page now fetches the destination and navigates itself; a script-initiated navigation is
not governed by form-action. The redirect answer is kept for a caller without
JavaScript, where the chain stays same-origin until the provider takes over. The slug
in the JSON is not a disclosure: following the old redirect put it in the address bar
and history anyway.

Verified in Chrome: the provider start endpoint is reached, zero CSP violations, zero
aborted requests — where before it was ERR_ABORTED plus a console violation.

STORED XSS IN THE PLATFORM ADMIN'S SESSION

admin.js interpolated user name, email and auth_provider into innerHTML unescaped, and
/register accepted an address whose local part was an img tag with an onerror handler —
no spaces, so it slipped the asserted-email check too. A reviewer registered
anonymously and got script execution on #/admin: the page operators are now emailed to.
Escaped, and registration refuses addresses that are not addresses. (The render bug
predates this branch; the reachability and the significance of that screen do not.)

ALSO

  - the org SSO button is secondary while a password still works; two identical blue
    buttons stacked sent people to their IdP by muscle memory after typing a password.

1609 tests, three clean runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 10:36:32 -05:00
ScreenTinker 85febe05c0 Fix a login-page dead end, an enumeration oracle, and three boot/limiter defects
From the regression sweep. The first is a genuine regression against main.

A RATE-LIMITED DISCOVERY PERMANENTLY DEAD-ENDED THE LOGIN PAGE

lookupOrgSso checked that a body PARSED, not that the request succeeded — and a 429
body is valid JSON. So `data.sso` came back undefined, the single sign-on button was
hidden, the password box restored, and the domain recorded as answered: permanently,
for the life of the page. On an SSO-only domain that is the worst outcome available —
the password box then returns 403 and the button the user is told to use is not on the
screen. Discover is 10/min per IP and one person filling in the form costs up to four
calls, so a few colleagues behind one office address is enough. The comment above that
code already claimed to prevent exactly this; it only ever covered the 5xx case.

THE SSO-ONLY REFUSAL WAS AN ACCOUNT-EXISTENCE ORACLE

403 for an address that exists, 401 for one that does not — from an endpoint whose own
lockout returns 401 specifically to avoid that. The DOMAIN check now runs BEFORE the
account lookup, so both answer identically; whether a domain uses single sign-on is
already public through /sso/discover, so it reveals nothing new. The membership-level
refusal is deliberately downgraded to the generic 401, because a distinct answer there
would put the oracle back for exactly the accounts worth enumerating.

Verified: existing and invented addresses at an SSO-only domain both 403; and on an
instance with NO SSO configured, register/login/wrong-password/unknown-address are
201/200/401/401 — the hoisted check does not touch them.

BOOT PREFLIGHT

  - a cold install ran `npm ci --omit=dev` unconditionally, so a first start on a
    developer machine left `npm test` broken: same class of surprise as the prune this
    file already warns about, through the other branch of the same if. Now production-
    only.
  - two servers starting together: the loser died with ENOTEMPTY even though the tree
    was complete by then. It re-checks before failing.
  - the opt-out accepted only '1', unlike every other boolean the server takes.

THE LIMITER FOLD, DONE PROPERLY

Unmatched paths under /api/organizations still minted a bucket each. My first fix was a
catch-all regex — which put every unknown path in ONE bucket WITH the real endpoints,
so flooding nonsense URLs exhausted the limit for /sso-only. That trades a bypass for a
denial of service. Folding is now by explicit shape: known endpoints keep their own
keys, everything else shares a bucket kept apart from all of them.

Verified: 120 unmatched paths give 60/60 (bypass closed), and after that flood
/sso-only, /sso and /sso/:id/test all still answer 401 rather than 429 (no starvation),
while 70 hits on one real endpoint do trip its own limit. The login trailing-slash
bypass stays closed.

1609 tests, three clean runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 10:22:25 -05:00
ScreenTinker 37e22bb773 SSO-only: enforce per-domain, cover invited members, and stop the admin locking themselves out
A second attack round defeated three of the previous fixes and found a regression I
introduced. Each is reproduced-then-refused against a live server.

MEMBERSHIP: organization_members IS NOT HOW PEOPLE JOIN

Only three places write that table and nothing deletes from it — every INVITED user,
every admin-created account and every workspace assignment lands in workspace_members
and nowhere else. So keying enforcement on organization_members covered org owners and
people who had already used SSO: exactly the set the domain check already caught. A
reviewer invited an outside address into an SSO-only tenant, kept password login, read
the member list and content, and used it to invite more. Enforcement now asks whether
the user is in ANY workspace belonging to an SSO-only organization.

THE INTERLOCK ASKED THE WRONG QUESTION, TWICE

It fired only when a domain list became EMPTY, and it counted PROVIDERS. So:
  - replacing acme.test with decoy.test removed every proof and sailed through — two
    PUTs, and the customer's domain enforced nothing, with sso_only still reading true;
  - with two providers you could disable the one owning your staff's domain, because
    the other one, covering a domain nobody signs in at, still "enforced".
The question that matters is per-DOMAIN: after this change, is every domain that
enforces today still enforcing? Losing one needs the operator, whichever route gets you
there. The refusal now names the domain that would stop being covered.

REGRESSION I CAUSED: THE HAPPY PATH LOCKED THE OWNER OUT

Sign up with a personal address, create the org, verify the company domain, turn this
on — and enforcement covers you (you are a member) while your own address is outside
the verified domains, so passwords are refused AND your org's provider will not assert
for you either. No route removes a membership; reset succeeds but login still refuses.
Recovery meant a platform admin turning SSO off for the whole tenant. Enabling now
refuses when the actor's own address is not covered, naming it, and REPORTS everyone
else who will be stranded instead of letting them be discovered by support ticket.

ALSO

  - POST /api/admin/users gated only on the target workspace, so you could mint
    cfo@theircompany.test into your OWN workspace: login refused, but the row now has a
    password_hash and an SSO login will not adopt one — permanently locking a real
    person out of their own address. Now gated on the address's domain too.
  - `ceo@acme.test.` (trailing root dot) slipped the registration gate.
  - two rate-limited sub-paths were still unfolded because the generic org-id fold ate
    `sso-only` as an organization id; the specific shapes are matched first now.

1609 tests, three clean runs. Verified live: invited outsider 403, swap refused,
sibling-disable refused, squat 400, self-lockout refused with the address named, and an
on-domain admin gets `stranded_members` back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 09:51:40 -05:00
ScreenTinker 355b7a2b86 SSO: build the operator approval screen, and close the last of the QA findings
The approval workflow had no front door. The notification email told the operator to
"review it in ScreenTinker under Admin" and that screen did not exist — the only way to
approve was curl, while the tenant sat locked out of their own product. Admin now leads
with a removal-request section: who asked, for which organization, the reason they
gave, what approving does, and Approve/Reject. It hides itself when the queue is empty.
Approving is confirmed; rejecting is not, because rejecting only leaves the safe state.

REGISTRATION BYPASSED SSO-ONLY AND SQUATTED ADDRESSES

/register had no domain awareness: it issued a working session at an SSO-only domain,
and the account then held that address forever, because an SSO login will not adopt a
row that has a password. Registering ceo@acme.test before the real CEO's first login
left the address dead in both directions with no self-service way out. Refused now, and
"Create Account" is hidden on the login page for those domains — it was the only action
left on the card, so the page was inviting the one thing that cannot work.

THE NEW RATE LIMIT WAS DECORATIVE

/api/organizations carries three caller-chosen segments, and only the OIDC slug was
folded — so every request minted its own bucket. Measured: 120 calls with unique org
ids produced ZERO 429s, unauthenticated, against the limit that exists to bound
outbound discovery and live DNS. Now 60/60. The general problem was named in the
previous commit's own comment and then not applied to the mount it added.

XSS IN THE TOAST

showToast built innerHTML from server strings, including ones that reflect input
verbatim — a reviewer typed `<img src=x onerror=alert(1)>` as an issuer and got script
execution in the admin's session. Escaped.

ALSO

  - the org SSO button sat BETWEEN the "Password" label and its input, so the label
    described the button and the field had none; moved below the input, with a for=
  - the OR divider survived when the providers under it were hidden
  - provider action buttons were clipped off-screen at 375px with no way to scroll to
    them — "Remove" was unreachable; the row wraps now

1609 tests. Verified in real Chrome: 13/13 on the approval loop and the login states,
including approving a request and watching password login re-open for that org.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 08:48:53 -05:00
ScreenTinker 983bee31b7 SSO-only: close the backdoor, the unilateral disable, and the fresh-install fail-open
Three HIGH findings from the QA round. Each was demonstrated end to end against a
running server, and each is now refused there.

ENFORCEMENT PROTECTED A DOMAIN, NOT AN ORGANIZATION

ssoOnlyForEmail answers about an address's domain, so any account in the tenant at an
outside address kept password login — a contractor, an MSP, the one address nobody
remembered. And it could be manufactured: POST /api/admin/users accepts workspace_admin
and creates a LOCAL password account at any address bound to that workspace. A review
created backdoor@notacme.test, logged in with the password, landed in the SSO-only org,
and used it to create another. Enforcement is now keyed on MEMBERSHIP as well as domain
(ssoOnlyForUser), and that route refuses to mint password accounts into an SSO-only
organization at all. platform_admin keeps both, as the operator break-glass.

THE APPROVAL WORKFLOW WAS DECORATIVE

`sso_only` is honoured only while a provider is enabled and a domain is verified, so
`PUT {enabled:false}`, `PUT {email_domains:""}` and `DELETE` each switched enforcement
off — with sso_only still reading true, no request filed and the operator never told.
The delete variant additionally rewrites every federated account to `local`, after
which a password reset takes over accounts the identity provider was supposed to own.
Anyone who could file a request could simply turn the provider off instead. All three
now refuse with sso_only_locked when nothing else would still enforce, and say to ask
for approval.

FRESH INSTALLS FAILED THE MIGRATION AND FAILED OPEN

The ALTER adding organizations.sso_only sat in the column-migration array, which runs
BEFORE the multi-tenancy migration that creates the table: `[migrate] FAILED … no such
table: organizations`, one line among ~85. The instance then ran its whole first boot
with the SSO settings screen 500ing and ssoOnlyForEmail catching `no such column` and
answering "not required" — password login proceeding for an organization that had
switched it off. It self-healed on the second boot, which is what made it easy to miss.
The column is now added after the table exists, and the catch distinguishes "this
instance has no per-org SSO" (null, so single-tenant installs keep working) from drift
on a table that DOES exist (throw). Login treats an undeterminable answer as "required"
rather than letting a 500 escape or letting the login through.

Verified live, all four refused with enforcement intact and the operator still able to
sign in. 1609 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 07:23:28 -05:00
ScreenTinker 901e664591 Fix: the router discarded every SSO return, so single sign-on could never complete
THE CRITICAL ONE. The server ends every SSO login by redirecting to `#/login?sso=1`
(claim the session) or `#/login?sso_error=<code>` (say what went wrong). The router
compared the hash EXACTLY against '#/login' in three places, so an unauthenticated
browser — the only kind that ever arrives there — had the hash rewritten to a bare
'#/login' and the query was gone before the login view ran.

  - a user who authenticated perfectly at their IdP landed back on a clean login page,
    still signed out, with no message: /api/auth/sso/claim was never called
  - all 16 error codes rendered SILENCE — not a raw key, not "undefined", nothing to
    report or search for
  - it took the pre-existing ?verified=1 email-verification toast with it

The comment above the reset-password exclusion describes this exact bug class and was
never extended to the login route. It is now, in all three places: the auth redirect,
the render dispatch, and the no-workspace guard.

Verified in real Chrome: 16/16 codes render a real sentence, and ?sso=1 now reaches
POST /api/auth/sso/claim.

Also, on a server with NO SSO configured, confirmed in the browser that the login page
is exactly what it was before any of this work: email, password, Sign In, Forgot
password, zero SSO buttons, no single sign-on wording, plain local login issues a
session, no page errors.

And fixes MY preflight, which pruned devDependencies as a side effect of BOOTING:
`npm install --omit=dev` reconciles the whole tree, so merely starting the server
deleted socket.io-client, puppeteer-core and js-yaml and broke `npm test`. A reviewer
watched it happen. It now installs only the named missing packages, with --no-save —
a boot-time repair that quietly removes packages is worse than the failure it fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:51:27 -05:00
ScreenTinker 601b526264 Boot: install missing dependencies and rebuild the native module before starting
scripts/upgrade.sh already runs `npm ci`, so this is not for the normal path. It is
for the ways a box ends up with the wrong node_modules, both of which present as
"server will not start" with an error naming a file rather than the action needed:

  ROLLBACK      checking out an older tag to back out a bad release restores that
                tag's package.json but not its packages. This branch removes
                google-auth-library, so a rollback to main would not boot — and you
                are rolling back because something else already broke.
  NODE UPGRADE  better-sqlite3 is compiled against one ABI. Upgrading Node makes every
                boot fail with NODE_MODULE_VERSION, which reads like database
                corruption and is not.

Runs as the FIRST statement in server.js, before any dependency is required, and uses
only Node builtins — anything it imported could be the thing that is missing. Repairs
with `npm install --omit=dev` (never `ci` on a partly-populated tree, which would
delete a working node_modules to fix one package) or `npm rebuild better-sqlite3`, and
exits with the command to run if it cannot. ST_SKIP_DEP_PREFLIGHT=1 opts out.

⚠️ The first version of the native check was WRONG and I caught it only by running it
under a real version mismatch: better-sqlite3's entry point is plain JavaScript that
loads the compiled binding lazily, so `require()` succeeds under a Node the binary was
never built for. It reported a genuinely broken install as healthy. It now opens an
in-memory database, which is what actually pulls the binding in. A test pins that,
because the failure is invisible — the check keeps passing on every machine where
nothing is wrong.

Verified: a deleted dependency is detected, installed and the server boots (200); the
ABI mismatch is detected under Node 18 against a module built for Node 20 and reported
clean under Node 20; a healthy tree costs 8ms and touches no network.

1609 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:44:26 -05:00
ScreenTinker 240f107f17 README: nest the SSO subsections under their parent headings
They were ### under a #### parent, so both rendered as siblings of the section they
belong to rather than inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:24:42 -05:00
ScreenTinker 751d5343da README: document SSO-only, and correct two claims that stopped being true
The account-linking paragraph still described the rule that caused the account
takeover — "an account with no password is re-pointed at whichever provider
authenticated it" — which has not been true since the confinement fix. And the
discovery endpoint no longer answers with a bare boolean; it also says whether SSO is
required, which is what lets the login page hide the password field.

Adds the "Requiring single sign-on" section: what it does, that instance-wide
providers are refused too (a side door, not a convenience), that removal needs a
platform admin, why the approval email carries no link, why platform_admin is exempt,
and that the approval queue becomes an availability dependency.

Also states the resolution order plainly — instance-wide is the default, an
organization overrides only its own verified domains — and removes a duplicated
paragraph about claim expiry left over from an earlier edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:24:31 -05:00
ScreenTinker 0e8ffa5444 SSO-only: an org may require its own identity provider, operator approves removal
Per-organization toggle. Enabling is the safe direction and an org admin does it
alone; turning it back off is a REQUEST that a platform admin has to approve, because
that is the direction that re-opens password sign-in — the direction a compromised
admin would take, and the one a customer will demand at their worst moment with the
IdP down.

  - requires at least one VERIFIED domain, so nobody can lock a company out of a
    domain they only typed, and an org cannot leave its own people with no way in
  - the login page HIDES the password field for those domains rather than letting
    someone type a password that will be refused and then go reset it
  - the refusal is `sso_required`, distinguishable from a wrong password
  - the approval email carries NO action link: a token that acts on its own turns
    every forwarded copy into a way to switch off a customer's SSO. The decision is
    made signed in as a platform admin.

INSTANCE PROVIDERS WERE A SIDE DOOR

Blocking passwords while leaving "Continue with Google" is not requiring single
sign-on, it is renaming the bypass — instance-wide providers are the operator's and
are NOT domain-confined, so one could assert an address at an SSO-only domain and walk
straight past the customer's MFA and deprovisioning. The callback now refuses any
provider other than that organization's own, and the page stops offering them.

Instance-wide stays the default everywhere else: an address whose domain has no org
SSO still gets local plus every configured instance provider. The org only overrides
for its own verified domains.

PLATFORM_ADMIN IS EXEMPT, DELIBERATELY

The operator approves turning this off. If the operator's own address sat at an
SSO-only domain and that IdP broke, nobody could sign in to approve anything and the
instance would be bricked. The exemption is the break-glass, and a test pins it as
source so it is not "tidied away" as a convenience.

BROWSER-FOUND

Hiding the password by hiding its .form-group also hid the organization SSO button,
which lives inside that same group — leaving a login page whose only action was
"Create Account". Only visible by looking at a screenshot. Hides the field now, not
the container.

Player untouched: this branch changes no device, WebSocket or provisioning file, and
the 358 device/player/socket/pairing tests pass.

1603 tests pass. Enforcement, the approval workflow and the login page verified in
real Chrome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:19:40 -05:00
ScreenTinker 481a44c15a SSO settings: show a verification outcome once, not twice
First real-browser pass over this feature. Chrome via puppeteer-core, driving the
actual settings page: login, the SSO card, and a real click on Verify.

The click path works — the loadSso fix holds, no ReferenceError, and a failure shows
the specific DNS answer ("no _screentinker-verify record found ... DNS can take a few
minutes") rather than the generic catch-all. But the outcome was rendered TWICE: the
server persists last_error on the row and the template drew it, while the click handler
wrote the same sentence into a second element underneath. Anyone retrying a failed
verification saw the identical line twice, in two different colours.

One element now owns the outcome, and the handler replaces its text. Also colours the
in-flight "Checking DNS…" as muted rather than leaving it red.

Found by looking at a screenshot. Parsing, VM rendering and mutation testing all passed
over it — none of them draws anything.

18/18 browser checks, 1598 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 21:25:35 -05:00
ScreenTinker c91b96ab91 SSO: refuse delegated proof names, release lapsed and deleted claims
Third review pass. It confirmed the crash wrapper holds (~13,000 hostile requests,
no fourth crash), the SSRF rewrite holds (77 vectors, every CIDR boundary proven),
the rate-limiter rewrite closed the login brute-force bypass, and /sso/claim rejects
every wrong token kind. It also found that two things I built yesterday did not do
what they claimed.

THE 8-HOUR LIMIT DID NOT BOUND SQUATTING

Pressing Verify on an expired claim REISSUED it in place, renewing the clock — so one
request per window held a domain forever, through the endpoint meant to enforce the
limit. Worse, a renewal was not a new claim, so the operator was notified exactly once,
on day zero: a tenant could sit on a company's domain for a year off a single stale
alert. A lapsed claim is now RELEASED. Re-adding it is an ordinary new claim: new
token, and the operator is told again. Squatting is not impossible; it is loud.

A DELEGATED PROOF NAME COULD FORGE A DOMAIN

A TXT lookup follows CNAMEs, and RFC 4592 means a wildcard `*.victim.com` synthesizes
`_screentinker-verify.victim.com` too — so a wildcard CNAME let whoever controls its
target prove a domain they do not own, turning an ordinary subdomain takeover into
every `@victim.com` login. A reviewer did this against a real authoritative zone. The
proof name is now refused if it is a CNAME, which is stricter than ACME's dns-01, and
the comment that claimed wildcards "cannot be mistaken for a proof" — true only for
wildcard TXT — has been corrected.

MY VERIFY BUTTON REPORTED FAILURE ON SUCCESS

`await load()` — the loader is `loadSso()`. The ReferenceError went into a bare catch,
so a correct DNS proof showed "Could not verify that domain" and left the card stale.
On the expired branch the admin kept publishing a token the server had already rotated.

ALSO FIXED

  - deleting a provider stranded its verified domains (no FK, UNIQUE, never expires) so
    the domain was blocked for EVERY org forever with no in-product recovery, and its
    users could neither sign in nor reset. Delete now releases the domains and returns
    the accounts to local, in one transaction; a cascade FK backstops it.
  - isOrphanedFederated read absence-of-config as proof-of-deletion, so unsetting
    GOOGLE_CLIENT_ID made every Google account password-resettable instance-wide, and
    irreversibly. Restricted to org-provider slugs.
  - `email_domains: null` (not undefined) took the destructive branch and deleted every
    DNS proof an organization had.
  - unbounded domain lists: 400 domains sent 401 emails; now capped at 50, one digest
    per save, and /api/organizations is rate-limited at all for the first time.
  - login and register responses carried password_reset_hash and email_verify_hash —
    live account-takeover credentials handed to the browser. One sanitiser now.
  - trailing-dot hostname (`https://localhost./`) slipped the SSRF guard.
  - asyncRoute's own catch could throw and kill the process it exists to protect.
  - a legacy DB whose typed domains were never verified now says so LOUDLY at boot
    instead of silently locking every federated user out.

TESTS

Two of the previous round's tests passed against the code they were named after: one
asserted UNIQUE against the test harness's own CREATE TABLE rather than the shipped
schema, the other used two different domains so no ordering was exercised. Both
replaced and confirmed load-bearing. Seven mutations now turn the suite red, including
removing the CNAME refusal, the verified_at filter, and the expiry itself.

1598 tests pass. Delete-release, lapse-release and the leak fix verified against a
running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 21:01:26 -05:00
Christopher Cookman d7227e6828
Merge pull request #3 from ChrisChrome/copilot/fix-rss-feed-ticker
Fixing RSS feed ticker issues
2026-08-10 19:33:58 -06:00
ScreenTinker 9155370ae8 SSO: TXT only for domain proof, drop the CNAME form
The CNAME alternative pointed at `<token>.verify.screentinker.com`. Making that work
means operating a wildcard DNS zone that answers for every token ever issued — which
this project does not have, so half the published instructions described a check that
could never pass. Documenting a verification path that cannot succeed is worse than
offering one form.

TXT needs nothing outside the customer's own zone, and the dedicated `_`-prefixed name
keeps it away from the apex where SPF and DMARC live. A wildcard `*.example.com` cannot
be mistaken for a proof either way: it answers with its own value, never the token, so
it lands in "exists but does not match".

Also simplifies check() — one lookup, no Promise.allSettled, and NXDOMAIN is reported
as "not published yet" rather than as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 20:27:03 -05:00
copilot-swe-agent[bot] d0c7ba28b7
Fix RSS ticker so scroll speed is content-independent
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-11 00:44:27 +00:00
ScreenTinker d4b8d7dad4 SSO: prove domain ownership by DNS, and fix what the second review found
A second review pass, run against the previous commit, found four blockers — two of
them introduced by the fixes in that commit. It also confirmed the original account
takeover is closed: a hostile IdP with real TLS, discovery, JWKS and RS256 driving the
real routers now stops at domain_not_allowed, and all 16 bypass variants are refused.

DOMAIN OWNERSHIP (the root cause, not the symptom)

A claimed domain used to mean "nobody else claimed it". It now means the organization
published a record in that domain's own DNS — TXT or CNAME, at a dedicated
_screentinker-verify name rather than the apex, where an edit would sit beside SPF.

  - an unverified domain routes NOBODY and cannot be asserted; it reserves the name
  - an unverified claim LAPSES after 8 hours, so a domain cannot be held against its
    real owner, and lapsing rotates the token so a record left over from an abandoned
    attempt cannot satisfy a later claim
  - a verified domain never expires — re-proving on a timer would log a customer out
    over a DNS edit made months later
  - routing and confinement read the VERIFIED set only, never the typed column
  - configuring SSO now requires a verified email address
  - platform admins are emailed when a domain is claimed; nothing is ever sent to the
    claimed domain, which would let any tenant make this product email third parties

Instance-wide providers are exempt from all of it: they are the operator's own
configuration and keep the trust they have always had.

BLOCKERS FROM THE REVIEW

  - two unauthenticated remote crashes, both one request, both "async handler throws
    before its try": `Cookie: st_oidc_tx=%` (unguarded decodeURIComponent) and the
    fail-closed secret added last commit, which turned a JWT_SECRET rotation into a
    permanent crash loop. Fixed the CLASS with asyncRoute() rather than the instances.
  - the SSRF guard was bypassable via IPv4-mapped IPv6 ([::ffff:127.0.0.1]) and also
    refused every host beginning "fc"/"fd" (fcm.googleapis.com). Addresses are now
    parsed and compared by RANGE. 42 cases verified.
  - the takeover fix had NO test — the test named after it asserted two struct fields
    and passed with the guard deleted. The decision is now a pure function and four
    mutations were confirmed to turn the suite red.
  - the PUT path never received the TOCTOU fix, so two orgs could end up holding one
    domain and forEmail handed routing to the attacker's older row.

ALSO

  - linking compared slugs, so an org could never rotate its own IdP, and fell open on
    an empty auth_provider. It now asks which ORGANIZATION owns the slug.
  - an account stranded by a deleted provider can be reclaimed by password reset —
    proof of the mailbox, which is stronger than the IdP assertion that created it.
  - /sso/claim accepted a pre-TOTP mfa_pending token and returned the full user row;
    it now takes a purpose-built 120s claim token with a pinned algorithm and typ.
  - the rate limiter keyed on a caller-controlled path, so a trailing slash bought a
    fresh bucket — a real login brute-force bypass.
  - domain_not_allowed and account_exists_other_provider rendered as "please try
    again", advice that can never work.
  - malformed asserted addresses are refused rather than trimmed into shape.
  - dead config (microsoftTenantId defaulted to 'common', which the provider code now
    refuses) and the orphaned google-auth-library dependency removed.

1591 tests pass. Domain lifecycle verified end to end against a running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 19:23:46 -05:00
ScreenTinker d26aaebef6 SSO: fix an account takeover, a remote crash, and login CSRF found in review
Five reviewers went at the two SSO commits. Three of them independently
demonstrated a full account takeover, and it was the same defect each time.

TAKEOVER. An org admin supplies the issuer and client_id, so they control that
identity provider completely and can mint an id_token asserting ANY email with
email_verified:true — including a platform_admin's. Every cryptographic check
passed honestly, because the attacker IS the issuer. upsertFederatedUser then
re-pointed the existing account at whichever provider spoke last, because the
only guard was `password_hash IS NULL` — and every SSO-created account has a
null password. Sessions were issued as the victim, and the victim's own login
then failed forever with subject_mismatch.

The rule came from the old Google handler, where it was safe: only the operator
could add a provider. Making providers customer-configurable turned it into a
takeover primitive and the assumption was not re-examined. Now an org provider
may only assert emails inside the domains it registered, and may never adopt an
account another provider established.

REMOTE CRASH, unauthenticated. The state comparison guarded on UTF-16 character
length while Buffer.from produces UTF-8 bytes, so a state of 43 characters
containing one multi-byte character reached timingSafeEqual with mismatched
buffers and threw — inside an async handler, which Express does not catch, which
server.js turns into process.exit. One request per restart killed any instance
with SSO enabled. Compared as bytes now, and /api/auth/oidc gained a rate limit.

LOGIN CSRF. The callback returned the session token in the URL fragment, so a
crafted link installed an ATTACKER'S token and silently signed the victim into
their account. The token now goes in a one-shot httpOnly cookie exchanged at
POST /sso/claim, which a link cannot forge.

FRONTEND, dead on arrival twice over. login.js used `await` in a non-async
function — a SyntaxError that takes the WHOLE app down, since app.js imports it
statically and there is no bundler. And `esc` was never imported, so the org-SSO
button could never render; the ReferenceError was swallowed by the catch written
for network failures. Both slipped through because `node --check` parses these
files as CommonJS and exits 0 on a broken module. The correct check is
`node --input-type=module --check`, and all four frontend files now pass it.

PUBLIC EMAIL DOMAINS cannot be claimed. A tenant had claimed gmail.com in
review, after which every Gmail user typing their address was offered "sign in
with your organization" pointing at that tenant's infrastructure — phishing from
this product's own login page. server/lib/public-email-domains.js.

MICROSOFT multi-tenant is refused rather than silently broken. `common` metadata
advertises the literal template {tenantid}, so the issuer never matches and
every login already failed; and loosening that check is nOAuth. A tenant GUID is
now required, with a loud warning at boot.

SSRF: https only, loopback/RFC1918/link-local refused, redirects not followed,
and the test endpoint no longer echoes upstream status for a caller-supplied
jwks_uri (it was a readable internal port scanner).

Also: an omitted email_verified was accepted (the comment already said it should
not be); the domain-uniqueness check raced an 8s network call before its insert
and is now inside the transaction; same-org duplicate domains were allowed and
made routing depend on table-scan order; routing is now ordered; a client secret
that cannot be decrypted fails closed instead of silently downgrading to a public
client; SSO audit rows were writing the org id into the deviceId column; and
/sso/start was capped at 10/min per IP, which would 429 the 11th employee behind
a corporate NAT.

Adds per-provider editing in the org admin UI (replace-only secrets — never
returned, blank means keep, explicit clear) and a Test button that checks
discovery, endpoints and signing keys while stating plainly that it cannot
verify the client ID, the secret, or the redirect URI registration.

⚠️ STILL MISSING: domain-ownership verification. A claimed domain means "nobody
else had claimed it", not "they own it". DNS TXT proof is the remaining control.

1582 tests pass. New regression tests cover the takeover confinement, ordering,
fail-closed secrets, the Microsoft refusal and the public-domain blocklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 18:12:07 -05:00
Christopher Cookman 295f4aecb1
Merge pull request #2 from ChrisChrome/copilot/add-organization-widget-sandbox-setting
Add org-level widget sandbox isolation override with explicit risk gating and global warning
Code changes were manually reviewed, minor changes made.
2026-08-10 17:04:57 -06:00
Christopher Cookman 1e329b0b80
Remove focus timeout for input element
Remove input focus timeout on overlay load.
2026-08-10 16:41:13 -06:00
ScreenTinker e97228a502 SSO: per-organization providers, configured by the customer
Instance-wide providers belong to whoever runs the server. These belong to a
CUSTOMER: an organization points ScreenTinker at its own identity provider from
Settings → Single sign-on, with no environment variable and no restart.

The login flow is unchanged. An org provider is resolved through the same
oidc-providers.get(slug) the env ones go through, so there is one authorization
request builder, one token exchange and one verifier — not a second, less
tested path for tenants. That seam is why Phase 1 put provider lookup behind a
single function.

⚠️ An org provider is NEVER published. It is not in /api/auth/providers, because
listing a customer's IdP would both offer it to people it does not belong to and
leak the customer list from the login page. It surfaces only when someone types
an address at one of that organization's domains; otherwise the instance-wide
buttons are what you get.

The discovery endpoint answers with a BOOLEAN and nothing else — no slug, no
display name. Returning "yes, Acme Corp SSO" would turn a guessed domain into
confirmation that Acme buys this product, and the slug would hand out a working
entry point to their tenant. POST /sso/start repeats the lookup server-side and
redirects, so the browser never learns which provider it is being sent to until
the provider says so, and the address travels in a body rather than in a URL
that lands in history, proxy logs and a Referer. Both endpoints rate limited to
10/min.

Other properties, each with a test:
  - slugs are RANDOM, not chosen, so two customers cannot collide on or guess
    each other's URL
  - a domain may be claimed by ONE organization; a second claim is refused, so a
    tenant cannot capture another company's logins
  - the issuer is verified by live discovery BEFORE the row is written, so a
    typo is caught at configuration rather than by a user staring at a failed
    login
  - client secrets are optional (PKCE), stored AES-256-GCM via lib/secretbox,
    never returned; an absent secret on update leaves the stored one alone,
    which is how a settings form that cannot show it avoids blanking it
  - cross-org access answers 404, not 403, so an outsider cannot confirm that an
    organization id exists
  - signing in through an org provider grants membership of that organization,
    but never changes an existing member's role

Verified live end to end: creation against a real issuer, domain normalisation
(`@Acme.CO.UK` → `acme.co.uk`), boolean-only discovery, a rejected domain
squat, a rejected bad issuer, and 404 for a foreign organization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:33:32 -05:00
Christopher Cookman a88687a11b
Prevent input focus from scrolling
Remove focus from input to prevent scrolling behavior.
2026-08-10 16:21:28 -06:00
ScreenTinker 252854d31e SSO: one OIDC flow for every provider, and verify the token properly
The OAuth support that was here could not work and would not have been safe if
it had.

It could not work: the login page called google.accounts.oauth2 and
new msal.PublicClientApplication, and NEITHER SDK WAS EVER LOADED by any page
in this app — no script tag, no dynamic import, nothing. Both buttons threw
ReferenceError on click. Even had they loaded, the CSP allows scripts only from
'self' and cloudflareinsights, and frames only from self and YouTube, so the
libraries and their popups were blocked too.

It would not have been safe: both endpoints authenticated with an ACCESS token
and neither checked who it was issued for. POST /auth/google fell back to
tokeninfo?access_token= and read the email out of the reply; POST
/auth/microsoft handed the bearer token to Graph /me and trusted that. Graph
and tokeninfo will both describe the user behind a token minted for SOMEBODY
ELSE'S application, so any site a user signed into that requested `email` or
`User.Read` could have replayed their token here and been issued a session as
them. Both endpoints are deleted; nothing is lost, because nothing could reach
them.

Replaced by ONE generic flow — Authorization Code + PKCE (S256), run
server-side, with the provider list resolved through a single function so
per-organization SSO can extend it later without a second login path. Google
and Microsoft become ordinary configured providers; Okta, Keycloak, Authentik,
Auth0 and anything else that speaks OIDC now work with three env vars.

Because the exchange happens server-side the browser never talks to the
provider, so there is no SDK to load, no client id in the page, and no
third-party origin needed in the CSP.

Identity comes from an ID token that must survive: signature against the
provider's JWKS (asymmetric algorithms only — alg:none and HMAC are refused
outright, the latter because the only key we hold is public), `iss` exactly as
discovered, `aud` and `azp` matching our client, `exp`, and a `nonce` this
server minted for that login. State is compared in constant time against a
value in an httpOnly SameSite=Lax cookie, so the callback is CSRF-protected and
survives a restart mid-login.

Account rules are the ones already in place: a verified email is required, an
SSO login never takes over an account that has a password, and a changed `sub`
for a known address is refused rather than handing the account to a recycled
mailbox.

18 new tests, every one describing something the old code would have accepted:
cross-audience tokens, azp mismatch, replayed nonces, alg:none, HMAC forgery,
wrong signing key, expired tokens, a discovery document lying about its issuer,
and a registry that never leaks a client id or secret to the browser.

Verified end to end against Google's real discovery document: the redirect
carries response_type=code, PKCE S256, state and nonce, and every callback
guard rejects as intended (no cookie, wrong state, no code, provider refusal,
unknown provider).

⚠️ TOTP is still not prompted on an SSO login, matching the documented
behaviour of the previous SSO and API-token paths. That is a product decision
and is left unchanged here rather than altered silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:19:28 -05:00
copilot-swe-agent[bot] d7f87ce0bd
Fix main content width shrinking to narrow column
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-10 22:14:10 +00:00
copilot-swe-agent[bot] eeab23e0d3
Fix banner shifting whole dashboard layout
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-10 22:06:45 +00:00
copilot-swe-agent[bot] 1e278ea373
Fix banner persistence across view switches and modal scroll on open
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-10 21:56:03 +00:00
copilot-swe-agent[bot] 4fe2552971
Fix banners: prepend inside #app instead of inserting before it as sibling
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-10 21:45:44 +00:00
copilot-swe-agent[bot] 9f83832f7d
Fix banners overlapping sidebar by adding margin-left matching sidebar width
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-10 21:38:32 +00:00
copilot-swe-agent[bot] f725186905
Add org-level widget sandbox isolation toggle with warnings
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-10 21:14:42 +00:00
copilot-swe-agent[bot] 2cbb8e6349
Initial plan 2026-08-10 20:59:20 +00:00
ScreenTinker 28885d1a13 Merge branch 'feat/brightsign-ip-from-js'
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
# Conflicts:
#	server/test/device-controls-hidden.test.js
2026-08-10 15:40:31 -05:00
ScreenTinker 9e5d05aa33 Merge branch 'fix/brightsign-local-ip' 2026-08-10 15:38:56 -05:00
ScreenTinker 9b6f0856e0 Merge branch 'fix/pi-installer-245' 2026-08-10 15:38:56 -05:00
ScreenTinker 20d36e8923 Merge branch 'fix/player-parity-small-gaps' 2026-08-10 15:38:56 -05:00
ScreenTinker 08b3d7d404 BrightSign: report IPv6, the attached display and the active video mode
Follow-on from the Node-stdlib work, guided by BrightSign's own dev-cookbook
rather than by guessing at module names.

IPv6 costs nothing extra — it comes from the same os.networkInterfaces() call
the v4 address does. The column, the API field and the dashboard card have all
existed since 1.9.29 and no player has ever filled them; the card is written to
appear ONLY when set, precisely so the overwhelmingly v4 fleet does not pay
screen space for an empty row. fe80:: is skipped for the same reason 169.254 is
— a link-local address is scoped to one interface and cannot be dialled from a
laptop across the office. A ULA is kept, because that one is reachable.

The attached display and video mode are new columns, and they answer the first
question anyone asks about a dark sign: which panel is that, and is the player
outputting at all. screen_width/height could not answer it — they are what the
PAGE believes it has, i.e. the widget's own geometry. Our XT245 drives a CX101
at 1920x1200@60 while the page reports its own canvas.

Per telemetry row rather than on `devices`, because a display can be swapped,
unplugged or renegotiated without the player re-registering.

MULTI-OUTPUT: the output is chosen by screen number, not hard-coded. A
dual-output player registers ONE DEVICE ROW PER OUTPUT (?screen=N →
output_index), so each row must report its own panel — otherwise a box driving
a lobby TV and a menu board shows the lobby TV twice. Both naming forms are
tried: probed on hardware, "hdmi" and "HDMI-1" both resolve to output 1, while
a second output that does not exist fails cleanly ("hdmi2" throws from the
constructor, "HDMI-2" rejects), so a single-output player reports nothing
rather than inventing a screen. That case has its own test.

Dashboard: two cards, shown only when the player reports them, like every other
card in that block.

Verified end to end on the real XT245 (FW 9.1.93.2) — attached_display=CX101,
video_mode=1920x1200@60, alongside local_ip 192.168.1.46, 119616 MB disk,
3656 MB RAM and live CPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 15:30:40 -05:00
ScreenTinker def30e6d39 BrightSign: report the LAN address, real disk, memory and load
Every one of these fields existed in the schema, the API and the dashboard,
and every one was NULL or misleading on a BrightSign. The XT245 had 6000
consecutive telemetry rows with local_ip NULL while sitting at a perfectly
reachable 192.168.1.46, and reported "1026 MB" of storage for a 119 GB NVMe.

The host half (autorun.brs) does collect an address, but nothing the host
sends was arriving at all — proven by the storage figure, which was the
browser's cache quota rather than any disk. So the page has to read this
itself, which is also the half that can be delivered: st-bridge.js is served
per page load, while autorun.brs needs a release bump to reach a player.

It is Node's standard library, not a @brightsign module. The widget is created
with nodejs_enabled, so os and fs are simply there — this is what BrightSign's
own dev-cookbook does in html5-app-template (both the .ts and .js variants).

Looking for a platform module is the trap, and it cost most of a day:
@brightsign/networkconfiguration EXISTS but exposes only callback,
getNeighborInformation and enableLeds — no config reader. hostconfiguration
has getConfig()/applyConfig() but returns host settings (forwardingEnabled,
hostName, loginPassword, nameServers) with no address in them. Both enumerated
on the live player, because the JavaScript API doc pages 404 and BrightSign's
own roNetworkConfiguration page links to one of the dead URLs.
getCurrentConfig() is BrightScript-only.

  local_ip          os.networkInterfaces(), skipping internal and 169.254
  ram_total/free    os.totalmem() / os.freemem()
  cpu_usage         1-min load average / core count, as a clamped percentage
  uptime_seconds    os.uptime() — the MACHINE, overriding the page's own
                    performance.now(), so a widget rebuilt by the watchdog no
                    longer hides weeks of real uptime
  storage_*         fs.statfsSync over the mounts under /storage, largest wins
                    (ours boots from NVMe with a dead card slot; others from SD)

Dashboard: the RAM and CPU cards were gated on "is this Android?", which was
right when Android was the only family that could measure them. They now
render for any player that reports the value, so a BrightSign gets them and
Android is untouched — including keeping its "--" cards when no reading has
arrived, since an empty card is a known state and a missing one reads as
"cannot". The BrightSign storage card loses its "player storage" caveat,
because the number is now the disk it always claimed to be.

Verified on the real XT245 (FW 9.1.93.2): 116.8 GB free of 116.8 GB, 2.68 GB
of 3.57 GB RAM, 3% CPU, uptime tracking the machine, local_ip 192.168.1.46 —
matching the address found independently by MAC-vendor scan, and a disk figure
matching the kernel's own block count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 15:05:06 -05:00
ScreenTinker c2033b95fb BrightSign: find the LAN address on any interface, and say when there is none
The dashboard has a "Local IP" field, the server stores it, the bridge relays
it and autorun.brs collects it — the whole path has existed since 1.9.29. It
has never once produced a value for a BrightSign. Our XT245 has 6000 telemetry
rows with local_ip NULL while sitting on a healthy PoE network at
192.168.1.46, and every other field in the same payload arrives.

Confirmed against the device itself over its DWS: the installed autorun.brs is
ours (61055 bytes vs 61058 in tree) and contains this exact code, so it runs
and yields nothing. Interface 0 alone is not enough.

Now walks every interface the platform documents — 0/"eth0", "eth1",
1/"wlan0" — instead of assuming the first answers. The string forms are the
point: per the Object Reference an INTEGER interface "must currently exist on
the player; otherwise the object-creation function will return Invalid", while
the string names carry no such condition.

And when nothing answers it now says so on the host log. Silence is what made
this invisible for a whole fleet: the column stayed NULL and read as a
server-side gap rather than a player that never sent anything.

Not fixed blind — the first attempt at this used roDeviceInfo.GetIPAddrs(),
which is ROKU's API. BrightSign's roDeviceInfo has no network method of any
kind; the string does not occur once in the published Object Reference. It
would have raised "Member function not found" from inside SendHostTelemetry,
once a minute, forever — while ostensibly fixing telemetry. Caught by checking
the docs before shipping, and now added to the deny-list in
brightscript-api-surface.test.js so the next person cannot repeat it. Verified
the entry bites: injecting the call fails that suite.

⚠️ Untested on hardware. BrightScript has no interpreter outside a player, so
this is docs plus block-balance checking. The XT245 is reachable at
192.168.1.46 (DWS on 8080, not 80) to confirm once the package updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 11:47:23 -05:00
ScreenTinker 6ac272af57 Pi installer: stop advertising what was never installed (#245)
Three reports from the same operator, two of them the script describing a
state it never reached — the same shape as the first round of #245.

Guide missing sudo: frontend/guides/raspberry-pi-digital-signage.html said
`curl -sL … | bash` while the script's own header and its root check both say
`| sudo bash`. The script fails loudly with the right command, so nothing is
half-installed, but the guide should not have to be corrected by an error
message. Also documents the --player-only form, which the guide never showed.

MOTD advertised commands that mode did not create: section 11 creates
screentinker-status/update/logs only when PLAYER_ONLY is false, while section
12 wrote an /etc/motd listing all three unconditionally. A Player-Only Pi
therefore greeted its operator with three commands that were not on it, at
every SSH login. The command list is now appended per-mode.

The cheap fix would have been to print nothing on a player. That trades a
wrong banner for a machine nobody can inspect over SSH, so Player-Only now
gets its own screentinker-status (kiosk state, which server it points at, and
whether that server is actually reachable) and screentinker-logs (kiosk).
screentinker-update is genuinely not applicable — there is no local server to
update — and is not offered.

Wayland cursor never hidden: the launcher stated the compositor cursor config
was written "below when wayfire.ini exists". It never was — wayfire.ini and
hide_cursor each appeared exactly once in the whole script, both inside that
comment. unclutter is installed but only runs on the X11 branch, so a Wayland
Pi kept a mouse pointer on the sign while the install looked complete. Now
configures wayfire's hide-cursor plugin at install time, idempotently and
after backing the file up, and says plainly that labwc has no equivalent
rather than failing silently.

Tests: raspberry-pi-setup.test.js gains a check that no MOTD advertises a
command its mode does not install (both modes, extracted from the script
rather than re-typed), that a player is not left with zero diagnostics, and
that the Wayland cursor claim is backed by code outside a comment. Both
mutations verified to fail: putting screentinker-update back in the player
MOTD, and removing the hide-cursor write.

NOT fixed, and not guessed at: the ALT+F4-on-first-pairing symptom and the
reconnect storm. The crash-restore fix those would need is already in 1.9.33
and targets a different symptom, and `observed=6/5 per 10000ms` is six
reconnects in ten seconds, which matches neither the solo-widget cycle nor the
kiosk RestartSec=10. Both need the kiosk-side log — which, until this commit,
a Player-Only Pi had no command to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 10:50:34 -05:00
ScreenTinker 3333d5e968 Stop Android panels losing controls when they update
A declared capability set REPLACES the per-platform baseline rather than
merging with it, so anything the baseline grants and the player omits is a
control the operator loses by updating. Three were being lost.

- display.brightness: the per-window dim (setWindowBrightness) is Tier 0 —
  no permission, no owner, no WRITE_SETTINGS — and MainActivity applies it
  unconditionally. It was simply never declared.

- remote.screenshot / remote.stream: gated on the accessibility service,
  while captureScreen() falls through to ScreenshotCapture.captureView,
  a plain view draw with no permission check. A Tier-0 panel lost live view
  and screenshots by updating, and a GRANTED MediaProjection never became a
  capability either — consent given, capture working, server still refusing,
  because nothing re-declared.

- system.device_owner: no player declared it, so the server accepted
  system.kiosk as a stand-in for every Tier-2 command. Declaring the
  canonical name makes refusals say what they mean; the stand-in can retire
  one release after this reaches displays.

display.power stays conditional on purpose: screen_on works anywhere via a
wake lock but screen_off needs owner/admin/accessibility, and a control that
sleeps a panel it cannot wake is worse than no control. It is the sole entry
in the DELIBERATE set in player-parity-baselines.test.js.

Also fixes the capture-bootstrap gate in device-detail.js. It hung off
can('remote.screenshot'), which hid the button from exactly the panels that
need it. The gate is now Android-and-nothing-else, NOT "Android that lacks
capture": /api/devices/:id ships capabilitiesFor(), which flattens declared
and baseline into one array, and the android baseline contains
remote.screenshot — so a "lacks capture" test hides the button from all ~440
undeclared panels in the field. isAndroidDevice() mirrors platformFamily()
with all four signals in order; an Android-test-only helper classified every
Tizen TV as Android, since Tizen registers android_version 'Tizen 6.5'.

Tests: the suite could not see any of this. Mutation testing showed deleting
either capability line, or reverting isAndroidDevice to its buggy form, left
all tests green. Added an update-invariant test (declared set vs baseline,
with an argued exception list), a test that executes the shipped helper
rather than the harness stub, and a legacy-panel test using the shape the API
actually returns instead of one it never produces. All four mutations now
fail.

Verified on a real Android 16 device across all three tiers: Tier 0 captures
live video (no accessibility, no MediaProjection, no owner), Tier 1 gains
display.power via accessibility, Tier 2 declares system.device_owner and every
Tier-2 command delivers. An in-place upgrade from the pre-change build lost
nothing and gained exactly these three.

Baselines deliberately NOT moved — a baseline entry moves in the release
AFTER the one carrying the player fix, once it has reached displays.

Parity gaps 3 and 4 were implemented, audited and reverted; docs/player-parity.md
records why so the next attempt starts from the traps. Gap 3 (wiring "Force
update") meets an unbounded synchronous download against a 120s watchdog and a
3-attempt counter with no version binding, so three presses refuse a panel every
future version. Gap 4 (deferring to BS.capabilities()) removes working
screenshot/stream from diskless BrightSigns that capture to RAM, over-declares
transitions, and rides a probe timeout that discards a late answer permanently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 10:31:33 -05:00
ScreenTinker f58c537d15 chore(release): v1.9.33
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-08-07 20:47:58 -05:00
screentinker 04068f7f0a
Merge pull request #253 from screentinker/feat/web-player-live-debug-log
Live debug log on the web player — and the playlist-skipping bug it found
2026-08-07 20:47:05 -05:00
ScreenTinker b9bd83c48f Fix a boot-time TDZ that bricked a player across reboots
A BrightSign XT245 on shipped 1.9.32 went dark and STAYED dark. The exit beacon:

  crashed: Cannot access '_videoCompositingOk' before initialization @ player:3730:12

Boot restores the CACHED playlist and renders item 0 immediately, from a call site
~2300 lines above where `_videoCompositingOk` was declared. When that item was a
video carrying a transition, `isVideoBufferable` read the binding while it was still
in the temporal dead zone. A TDZ read is a THROW, not a `null`, so the player died
during boot.

The nasty part is the loop. The offending playlist came from the device's own
localStorage cache, so the player never stayed up long enough to receive a corrected
one -- every boot re-read the same poisoned cache and died the same way. Rebooting
the player, the one remedy an operator has, did nothing. Recovery took editing the
served player; nothing reachable from the dashboard would have helped.

Fixed by declaring the cache in State, above Boot, where no call path can reach it
early. Left a comment at the old site saying why it must not move back -- next to its
function is exactly where it looks like it belongs.

Not BrightSign-specific: any web-based player could hit it. Prod is not currently
triggering it -- the one exposed playlist starts on an image, and the video check
short-circuits before the read -- but that is luck, not safety. Reordering that
playlist, or a daypart making a video the first active item at boot, arms it for
those displays.

Found while testing hwz routing for video transitions; the crash is unrelated to
that work and reproduced on the unmodified released file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 20:39:14 -05:00
ScreenTinker 047f95f40c Freeze and copy the live debug log, and unstick the control row
Three small things off the device page.

The control row had margin-top but no margin-bottom, so the buttons sat flush on
top of the STATUS card and the destructive ones read as part of the status panel.

Freeze is the one with a decision in it: it holds the VIEW still and keeps
buffering underneath rather than pausing the stream. The moment you freeze a log to
read something is the exact moment the lines that explain it are still arriving, so
dropping them would throw away the part you were about to want. Resume replays them
in order. The held buffer is capped at the same 500 as the panel, and the status
text says how many are waiting -- otherwise a frozen panel is indistinguishable from
a device that went quiet, and silence reads as a symptom. Overflow says so too.

Copy takes what is on screen (not the held lines -- the paste must agree with the
panel) and stamps it with the device and an ISO timestamp, because a pasted log with
no device in it is a log nobody can act on. It falls back to execCommand when
navigator.clipboard is absent, which is every self-hosted dashboard on plain http:
that is not a secure context, and the other copy buttons in this app quietly do
nothing there.

Clear earns its place next to Copy: without it you always copy 500 lines of history
instead of the capture you just made.

The hint promised the stream "turns off on its own when the device reconnects",
which was never true and is not what happens now -- it turns off when you leave the
screen, and on the device after 30 minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 19:26:03 -05:00
ScreenTinker 24e430b354 One broken clip, one skip: stop media errors advancing the playlist N times
Found the day the live debug log started working, which is the only reason anyone
saw it. A BrightSign XT245 playing a 40s clip as a SINGLE-item playlist logged four
`Video error` events at every loop boundary and then three back-to-back "Playing:"
lines, with `play() rejected AbortError` and `muted-fallback play() also failed` in
between as the second mount aborted the first. On a one-item playlist that just
re-plays the same file, so it looked like nothing.

On a real playlist the identical storm skips one item per surplus event. Silently.
The operator sees a playlist that drops content and nothing says why. Same family as
234.

Two independent defects produced it:

1. `video.onerror` had no once-guard — its sibling in the buffered path has
   `if (done) return`, this one didn't — so every event scheduled its own nextItem.

2. Every call site wrote `advanceTimer = setTimeout(...)` DIRECTLY. A second write
   before the first fired ORPHANED the earlier timer instead of cancelling it: still
   pending, no longer referenced, so renderContent's clearTimeout could only ever
   cancel the last one. All the others fired. That made a dozen sites capable of
   leaking a timer, not just the error handlers — so the fix is a scheduleAdvance()
   helper that clears before it arms, and a test asserting nothing assigns the timer
   directly ever again.

The four error handlers (buffered/non-buffered x video/image) had drifted apart
because they were four copies; they now share one mediaFailureSkip(), which also
reports the actual MediaError code. The old line logged the DOM event
({"isTrusted":true}) and never touched el.error, so the log could say a video failed
but never why.

Third guard: an element that is still playable is not a failure. `error` fires with
el.error set; an event carrying no MediaError against an element with frames buffered
ahead of it did not fail at anything, and discarding a healthy item on that basis is
worse than the event being reacted to. Anything genuinely unplayable (no MediaError
AND nothing decoded) is still skipped, so a broken clip can never stall the playlist.

Verified on the XT245: 150s of playback went from 2-3 advances and an AbortError pair
per loop boundary to exactly one advance and zero AbortErrors, and the surviving
diagnostic now names the real cause -- `code=3 DECODE`, four raw error events
collapsing to one reported failure.

All three guards are mutation-tested: removing any one of them fails a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 19:12:16 -05:00
ScreenTinker c594a1a67a Make the live debug log work on the web player, and so on BrightSign
The dashboard's per-device "Debug logging" checkbox has always sent a `set_debug`
command. The Android player honours it — DebugLog.* mirrors its tagged lines over
the device socket while the box is ticked. The web player never implemented the
command at all, so the panel opened, revealed itself, and streamed nothing but the
three unconditional reporters (sync, pip, zone). A display could be failing loudly
in its own console and look mute from the dashboard.

In a browser that is a nuisance — press F12. On BrightSign it is the whole
diagnostic surface: no console, no adb, no logcat, a panel on a wall.

Rather than hand-instrument eighty-seven call sites to match Android's tag by tag,
this streams the ring buffer the error trap at the top of <head> has always filled:
every console.log/warn/error, every uncaught error with file:line and stack, every
unhandled rejection, every failed resource load. Turning the stream on also REPLAYS
that backlog, so the operator sees the failure that happened before they opened the
screen — the case they actually came to investigate, and one no log tail gives them.
Replayed lines carry their real age, because the dashboard stamps on arrival and 200
lines would otherwise all claim to have happened this second.

The bracket prefixes the player already uses ([wall], [bs], [group-sync]) become the
tag column, so the panel reads the same shape as Android's, and the panel now colours
by level — all four rendered identically before, so the one line explaining the fault
sat in a wall of grey.

Bounded three ways, because this sink is fed by console.*:
  - 40 lines/sec, over which lines are COUNTED and reported, not queued
  - auto-off after 30 min, for the checkbox nobody unticks
  - the dashboard also switches it off when the operator leaves the screen

The reentrancy guard in pushLog is not theoretical: the sink runs inside the console
wrapper, so a subscriber that logs anything would recurse until the stack gave out
and the player would die of its own diagnostics.

BrightSign host lines stand their direct emit down while the stream is on (the
console path already carries them) but still go out unconditionally when it is off —
the boot report is the one diagnostic nobody can ask for in advance, because it is
over before the operator has a device to open.

Verified on the XT245 on alpha: 34 lines across 7 tags, backlog replayed with real
ages, levels intact, platform line reporting BOS 9.1.93.2 / XT245 / 1920x1200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 18:31:18 -05:00
screentinker a44d3f8a77
Merge pull request #252 from screentinker/ui/device-controls-above-the-fold
Some checks failed
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Shaders / Compile transition shaders (real WebGL) (push) Has been cancelled
Put the device controls where the operator is looking
2026-08-07 18:02:20 -05:00
ScreenTinker ab44a2efc6 Put the device controls where the operator is looking
Reboot, screen off/on, launch player, force update and shutdown sat at the
bottom of the Info tab, below the info grid, the uptime timeline, the incident
list, the reboot schedule and the debug log panel. They are the actions someone
opens a device page to take, and reaching them meant scrolling past everything
that merely describes the display — worst on a phone, which is where an operator
standing in front of a dark screen actually is.

Moved to the top of the tab, directly under the diagnostics panel and above the
info grid. Still one wrapping row, so a narrow screen reflows instead of
clipping, and each button still renders only where the display can honour it —
the capability gating is untouched, so a panel that cannot reboot still shows no
reboot button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 17:57:10 -05:00
ScreenTinker b9b1870472 chore(release): v1.9.32 2026-08-07 17:36:46 -05:00
screentinker f17eaed8bc
Merge pull request #251 from screentinker/fix/brightsign-native-screenshot
A BrightSign photographs itself, using BrightSign's own API
2026-08-07 17:35:59 -05:00
ScreenTinker 4b6194884b A BrightSign photographs itself, using BrightSign's own API
This platform has never been able to screenshot itself. Video decodes onto a
hardware plane the DOM cannot read, so an in-page canvas composite comes back
with the content missing — the panel reported "Video is playing on the hardware
plane and cannot be captured" while playing perfectly.

@brightsign/screenshot composites the video and graphics layers, which is
exactly the thing a canvas cannot do. It is reached through the same Node
require() the widget already exposes — the one that also makes `module` visible
to classic scripts, which is what broke the shared UMD modules on this platform.
The same quirk caused that bug and enables this fix.

WHY THIS WORKS WHERE THE LONG WAY ROUND DID NOT.

The obvious route was to ask the HOST to capture through the player's own DWS,
because BrightScript can reach it. That is a dead end here: page->host messaging
stops working after page load, so the request never arrives — instrumenting the
host to echo the reason of EVERY roHtmlWidgetEvent produced nothing at all while
the page was posting. This API needs no host, no messageport and no DWS, so none
of that is in the path. The host route stays as a fallback for firmware without
the module, but it is no longer how this works.

The API writes a FILE rather than returning bytes, so it is read straight back
with Node's fs and sent over the socket the player already has.

TO RAM, NOT TO FLASH. The remote-control view drives this once a second, and a
screenshot per second written to the boot flash is a wear-out mechanism with
nothing to show for it: the file is read back and deleted microseconds later, so
it never needs to be durable. tmp is tried first and real storage only as a
fallback for a unit that does not present it. The directory must already exist
or the capture fails, so each candidate is checked rather than assumed.

Ordering is part of the fix: the native API is tried BEFORE the host route,
because trying the dead end first would spend an operator's patience on a 15s
timeout before reaching the path that works. Every failure still falls through
to the canvas, so a capture never comes back blank.

Remote streaming inherits all of it — startStreaming already drives the same
captureAndSend — so the live view now shows real video rather than a card
explaining why it cannot.

Verified on the hardware: a real 960x540 frame of the playing video, captured by
the player, delivered to the dashboard over its own socket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 17:28:00 -05:00
screentinker 77e3081675
Merge pull request #250 from screentinker/fix/brightsign-capture-port-and-queue
BrightSign capture: reach the right DWS port, and let the host collect its request
2026-08-07 14:21:38 -05:00
ScreenTinker 1ec32197b2 Let a BrightSign host COLLECT its capture request over HTTP
Server side of the inverted capture path. The host half is not here — see the
end of this message.

Every other player is TOLD to capture: the server emits device:screenshot-request
over the device socket and the page photographs itself. A BrightSign cannot
photograph itself. Video decodes onto a hardware plane the DOM cannot read, so
an in-page canvas returns a frame with the content missing — which is why that
platform has been answering screenshot requests with a card explaining that the
video is uncapturable. Only the host, through the player's own DWS, can get a
real frame.

The obvious way to ask the host is through the page, and it does not work. On an
XT245 (BOS 9.1.93.2) page->host messaging is dead after load: instrumenting the
host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all
while the page was posting, though the boot-time probe round-trips. The registry
is not an alternative either — a running BrightScript does not observe registry
writes made by anyone else, proven by writing the key externally through the DWS
and watching the host ignore it.

What the host CAN do is HTTP; it already fetches its own package updates that
way. So the direction is inverted: the request waits here and the host collects
it. The image comes back over a plain POST, which means a capture will work even
when the page is wedged — exactly when an operator most wants to see the screen.

Held in memory on purpose. A capture request is worthless a minute after it was
made — someone clicked a button and is watching for the result — so persisting
it would only add a way to deliver a stale screenshot after a restart. Bounded
and TTL'd so a fleet going offline mid-request cannot grow it, and a repeat
request REPLACES rather than queues so a 1fps stream builds no backlog.

Authenticated with the same device_id + device_token pair the socket uses.
/api/brightsign/package is public because a player fetches it before it has any
identity; a screenshot is a picture of a customer's screen and belongs to one
display.

deviceSocket now exposes ONE ingestScreenshot() used by both the socket handler
and the HTTP route, so a BrightSign screenshot reaches the dashboard by exactly
the route every other player's does rather than becoming a second, subtly
different feature. Note those exports must be attached AFTER
`module.exports = function setupDeviceSocket`, which reassigns the object —
attaching above it silently wipes them, which cost a debugging round.

NOT INCLUDED, deliberately: the host-side poll. Adding it to autorun.brs's main
loop kills the BrightScript script within seconds of boot — the page keeps
playing, because the widget outlives the script, so from the dashboard it looks
healthy. Cause unidentified; BrightScript runtime faults do not reach
/api/v1/logs, so there is no error text to read. Half a feature that silently
takes down the host is worse than none, so the server waits for a host that can
safely ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 14:15:06 -05:00
ScreenTinker f0f7f35103 Reach the DWS on the port it is actually listening on
A BrightSign screenshot showed a card reading "Video is playing on the
hardware plane and cannot be captured" while the very same capture worked
perfectly from the player's own DWS Snapshots tab. The player was asking the
wrong port.

autorun.brs hardcoded http://localhost/api/v1/snapshot/ — port 80. The DWS port
is configurable and BSN/Supervisor-provisioned players are commonly moved off
it: the unit this was found on serves DWS on 8080 with nothing listening on 80
at all. Every host capture therefore failed to connect and fell through to the
in-page canvas, which cannot read the hardware video plane — so the fallback
produced an honest-sounding message about the video, and the actual fault (a
port) never appeared anywhere.

The port lives in the networking registry section as http_server, which is the
same place the DWS itself is configured from, so that is where this reads it.
80 remains the default when the key is absent.

Also 127.0.0.1 rather than "localhost": a name has to be resolved, and if that
resolution answers ::1 first the connection goes to an address the DWS is not
listening on. A literal cannot be resolved wrongly.

This is necessary but NOT sufficient — the capture still does not work on that
hardware, for an unrelated reason recorded in brightsign/README.md: the page
cannot reach the host at all after load, so the Sub that would use this URL is
never entered. Fixing the port anyway, because it would have broken the capture
a second time the moment the messaging problem is solved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 14:14:45 -05:00
screentinker 04f88d3340
Merge pull request #249 from screentinker/fix/brightsign-umd-and-transition-guard
Export shared modules to the browser even when Node is in the page
2026-08-07 11:59:30 -05:00
ScreenTinker 95b8d1b293 Export shared modules to the browser even when Node is in the page
Transitions have never run on BrightSign, and it was never a GPU problem.

`transitionRuntimeReady()` is a presence check on three globals and touches no
WebGL at all. A BrightSign roHtmlWidget is created with `nodejs_enabled: true`,
which puts Node's `module` into classic-script scope — so every shared module
that exported with an `else` took the CommonJS branch and never assigned its
browser global. The runtime was absent before WebGL was ever asked a question.

This is deducible from the fleet without touching the hardware: the player
pushes system.reboot / display.power / display.resolution / system.self_update
only behind BS.hasHost(), which needs require('@brightsign/messageport') to
resolve. Our XT245's stored capability row carries all four, so Node
integration was live in that page, so the CommonJS branch was taken.

Transitions are the least of it. schedule-eval.js had the same shape, and the
player falls back to "always active" when ScheduleEval is missing — so per-item
DAYPARTING silently stopped applying on that platform and scheduled content
played outside its window with nothing in any log. player-media-health.js the
same. Four files, all fixed by exporting to BOTH targets rather than either/or.

media-mute.js, orientation-style.js and wall-geometry.js already assigned their
globals in a separate unconditional block and were never affected; the audit
that reached me claimed all seven, and reading them is what separated the four
from the three.

THE GUARD, WITHOUT WHICH THE ABOVE IS A REGRESSION.

Restore the globals alone and BrightSign starts attempting video wipes it
cannot supply. On a hardware video plane drawImage(video) succeeds, throws
nothing, and paints a fully TRANSPARENT frame — so the wipe fades from nothing,
behind a video plane that is still lit. Worse than the hard cut it replaces.

The discriminator already existed: videoFrameIsCapturable() probes ALPHA, so a
genuine fade-to-black still reads as captured. It was wired into the screenshot
path and not this one, which asked isMediaReadable() — a CORS question, "am I
allowed to read this", not "did any pixels arrive". Both the outgoing frame and
the incoming warm-play snapshot now consult it, cached per platform, defaulting
to available while undetermined so a cold start is not crippled.

Net effect on BrightSign: image-to-image transitions light up, anything
involving video hard-cuts honestly, and dayparting starts working.

Full video transitions are reachable later — BrightSign documents that video
"captured as a canvas for WebGL processing must be routed to the GPU" via a
per-element hwz="off", which keeps hardware decode at an 8-bit/1080p ceiling.
That needs the hardware to validate and is not in this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 10:36:50 -05:00
screentinker 0953823ee5
Merge pull request #248 from screentinker/fix/245-pi5-wayland-installer
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Pi installer: ask the operator, not the pipe; and stop assuming X11
2026-08-07 09:16:42 -05:00
ScreenTinker 0efbc6040e Pi installer: ask the operator, not the pipe; and stop assuming X11
Five defects from #245, all found by a user on a Pi 5 because nothing in this
repo has ever executed either of these scripts.

THE MENU IGNORED THE OPERATOR.

The documented install is `curl … | sudo bash`, which makes stdin the SCRIPT.
bash has consumed it by the time any `read` runs, so every prompt got EOF
instantly: the mode menu "chose" All-in-One without anyone touching it, and
Player-Only could not be reached that way at all. Reported as the menu being
skipped, because it was. Prompts now read the controlling terminal, and when
there genuinely is no terminal the script SAYS which way it went instead of
letting an empty answer look like a decision.

X11 TOOLS ON A WAYLAND PI.

Pi 5 on Bookworm defaults to Wayland, where xset, unclutter and xrandr are
no-ops that log an error and do nothing. The Pi therefore got no blanking
suppression and no cursor hiding while looking configured. The launcher now
detects the session and branches: X11 keeps what it had, Wayland gets wlopm and
--ozone-platform=wayland, and the compositor-side alternatives are documented
rather than silently assumed.

THE KEYRING PROMPT.

"Choose password for keyring" on every boot is Chromium reaching for
gnome-keyring. A kiosk has nobody to answer it. --password-store=basic.

THE WHITE PAGE ON EVERY BOOT BUT THE FIRST.

Chromium restoring a session it believes crashed — a kiosk is killed by
shutdown and never exits cleanly, so it returns with a restore surface over the
player. That is why ALT+F4 "fixed" it: it closed the surface, not the player.
Rewriting exited_cleanly was never enough on its own because the previous window
set is replayed from Sessions/, so that goes too.

THE BANNER SPELLED THE PRODUCT WRONG.

The ASCII art read "Scree Tinker" — the n was missing, and it is the first thing
anyone sees over SSH.

Also answered the reporter's Overlay FS question in the README, including the
part that bites: an All-in-One Pi IS the server, so an overlay discards the
database, uploads and JWT secret at every reboot. Safe for Player-Only; needs
DATA_DIR moved off the overlay otherwise.

The new test generates the kiosk launcher exactly as the installer writes it and
runs bash -n over it, because `bash -n` on the outer script cannot see inside a
heredoc — a syntax error in there is just text until it reaches a screen.

Reported-by: carloblu74
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 09:08:17 -05:00
screentinker 8b4b7af432
Merge pull request #247 from screentinker/thumbnail-backfill
Heal missing thumbnails: boot-time backfill, ffmpeg diagnostics + packaging, phantom-path fix
2026-08-07 08:54:51 -05:00
ScreenTinker 5b069b9665 Probe video asynchronously — the sweep would have blocked the loop per file
The backfill is right, and it lands on a path that could not carry it yet.

deriveMediaMetadata spawned ffprobe and ffmpeg with execFileSync, each with a
15s timeout. Synchronously, those two calls stop the whole server for their
duration: no heartbeats, no socket traffic, no HTTP. That was survivable while
the only caller was a human-initiated upload — one file, someone waiting on it,
bounded by their patience.

The boot-time sweep removes every one of those mitigations. It walks the entire
library, unattended, on a server with live panels, once per boot. A library of
video rows therefore becomes a per-file event-loop stall, which is #240's
failure mode — blocked loop, missed heartbeats, panels marked offline,
reconnect churn — arriving from our own maintenance instead of from a
checkpoint. We spent yesterday removing one of those; this would have added
another, on a schedule.

So both spawns are awaited instead of blocked on. Both callers already awaited
deriveMediaMetadata, so this is invisible to them, and the ingest path stops
freezing the server for the length of an upload's probe as a side benefit —
that sync ffprobe has been known tech debt for a while.

Timeouts are unchanged and still asserted: async is not a licence to hang, or
one wedged file stops the sweep dead instead of moving on.

Also applied the PR's own phantom-path discipline to the video branch, which
still named its thumbnail before the encode: a failed ffmpeg left the row
claiming a file that was never written, which is the exact bug the image branch
was fixed for two commits earlier.

The new test measures the property rather than grepping for it — a timer keeps
ticking across a real spawn — so a future edit that reintroduces a sync call
fails here rather than in a customer's fleet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 08:42:34 -05:00
ScreenTinker b00efa4f14 Merge main into thumbnail-backfill 2026-08-07 08:38:56 -05:00
screentinker a6a137daf2
Merge pull request #246 from screentinker/feat/ipv6-and-panel-scaling
Show a panel's IPv6, and size the pairing code to the screen it is on
2026-08-07 08:34:42 -05:00
screentinker 127a6265e0
Merge pull request #243 from a10kiloham/screenshot-ack-and-proxy-docs
Toast the screenshot-request verdict; document proxy header pitfall
2026-08-07 08:34:36 -05:00
ScreenTinker 9face2fdd4 Show a panel's IPv6, and size the pairing code to the screen it is on
Two field-reported gaps, unrelated except that both are about being able to
read something off a screen.

A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN.

DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no
address at all and the dashboard rendered a dash for a screen that was perfectly
reachable. It now reports both stacks in their own fields: a dual-stack panel
genuinely has two addresses and either may be the one you need, so collapsing
them into one column would make it mean "whichever interface enumerated first".

Link-local (fe80::/10) is deliberately excluded. Every interface has one, they
tend to enumerate first, and none can be dialled without also knowing the zone
index — so admitting them would fill the field with a string nobody can paste
anywhere and hide the address that works. Any %iface suffix is trimmed for the
same reason. The 45-char cap the writer already applied is exactly the longest
legitimate IPv6 text form, so it needed no change.

The dashboard card renders only when a panel actually has a v6 address, rather
than showing an empty row to the overwhelmingly v4 fleet.

THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST.

Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel
covers a quarter of the screen area on a 4K panel that it does on 1080p, and a
sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the
4K wall it was installed on, which is where signage actually goes.

What has to stay constant is ANGULAR size, so the root font size is now
viewport-proportional and everything on those screens is a rem against it. The
code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for
bit what it renders today, so nothing changes for the existing fleet — 144px at
4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px
viewport the root computes to 13.0473px, which is 0.926vmin to four decimals.

vmin, not vw, because portrait-mounted panels are common here and vw would
render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's
preview iframe stays legible instead of microscopic and an ultrawide does not
get silly. Applied to the web player (which BrightSign also runs) and to Tizen,
where a 1920x1080 logical viewport makes it arithmetically identical to the
values it replaces — the point being the panels where it is not.

A test asserts the scaling cannot reach playback content: the whole safety
argument is that only the chrome uses rem, and a stage or zone rule adopting it
would start resizing CONTENT, which is a worse bug than the one being fixed.
Android is untouched — its pairing code already autosizes within a dp-scaled
layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 08:06:45 -05:00
Rob K 7896cccf4d Install ffmpeg in the Docker image and document it as a requirement
The runtime image never had ffmpeg, so every Docker deployment silently
lost video thumbnails and durations; the README never mentioned it for
bare-metal installs either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 09:30:32 +01:00
Rob K bfe8a4c907 Backfill missing thumbnails at boot, and say when ffmpeg is absent
Ingest-time thumbnail generation is best-effort by contract, so a row that
misses it stays bare forever: video uploads on a host without ffmpeg (a
SYSTEM dependency nothing surfaced), or content from before thumbnails
existed. Operators read that as "thumbnails don't work".

Two additions. A [MEDIA] startup diagnostic (async probe, cached) states
loudly whether ffmpeg/ffprobe were found, mirroring the [EMAIL] block. And
a once-per-boot sweep re-derives metadata for local image/video rows with
no thumbnail — serial, paced, delayed past boot, unref'd. The sweep's row
UPDATE re-checks that thumbnail_path is still empty so it never clobbers a
thumbnail written concurrently by the replace flow, removes its just-written
file when the row vanished mid-derive, salvages probed dims/duration even
when the thumbnail itself failed, and stops after 25 failures per boot so a
library of undecodable clips can't turn every restart into subprocess churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 09:30:32 +01:00
Rob K 3f1c044940 Return no thumbnailPath when the image thumbnail write fails
deriveMediaMetadata assigned thumbnailPath before sharp wrote the file, so
a failed write (corrupt image, disk error) returned a name for a file that
was never created. Ingest then stored that phantom thumbnail_path and the
dashboard requested it forever as a broken image. Assign only after the
write succeeds; the video branch already nulled its path on failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 09:30:19 +01:00
Rob K 7d7be365f9 Warn against proxy-level security headers in the README
helmet already sets X-Frame-Options, HSTS, CSP, etc., and manages them
per route (widget/kiosk renders and the device preview remove or relax
X-Frame-Options so they can be framed). A proxy-level header block adds
a second copy, and browsers treat conflicting duplicate X-Frame-Options
values as deny - which blanks the same-origin /player iframe behind the
dashboard's Preview button. Seen in the wild behind a Caddy config that
added X-Frame-Options: DENY on top of the app's SAMEORIGIN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 08:52:26 +01:00
Rob K 0f2ec474f4 Surface the screenshot-request verdict as a toast
The server already acks dashboard:request-screenshot with
{ delivered, reason } (offline / unsupported via the capability
registry), but no dashboard sender passed a callback, so clicking
Screenshot on an offline device or an unsupporting player type showed
"Screenshot requested" and then silently did nothing.

requestScreenshot() now takes an optional callback using the same
.timeout(5000) pattern as sendCommand(); the device-detail Screenshot
button passes one and toasts the verdict (requested / unsupported /
offline / no response). The dashboard grid and the 5s Now Playing poll
keep firing-and-forgetting - no behavior change there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 08:52:19 +01:00
screentinker 16d8295373
Merge pull request #242 from screentinker/fix/wal-checkpoint-startup-line
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
The checkpointer startup line no longer describes a policy it stopped having
2026-08-06 22:58:48 -05:00
ScreenTinker 5fa6b2d07d A baseline moves when the fix reaches SCREENS, which is not one rule
Two changes that are really one idea: the parity model treated all four
players as if they update the same way, and they do not.

WEB AND BRIGHTSIGN GET audio.volume BACK.

The audit removed it because v1.9.28's index.html contained the string
set_volume zero times, and because the handler read payload.value while the
dashboard sends { level }. The second reason 1.9.31 fixed. The first was
reasoning from the wrong artifact: this player is SERVED BY THE SERVER, so a
browser panel runs whatever build is answering it, not the release its row was
created under. There is no browser panel stuck on the v1.9.28 player once the
server moves — and prod moved tonight. The slider works on those displays right
now while the baseline says it does not, so the dashboard is hiding a working
control from every display that declares nothing.

BrightSign comes with it, on the same served player. The unit-specific doubt is
whether a hwz player's media element is reachable at all — and that is already
answered by audio.mute, which this baseline has always claimed: set_volume
reaches setMediaVolume() and device:mute-changed reaches currentVideoEl.muted,
same element, same path. If hwz swallowed one it would swallow both.

TIZEN DOES NOT COME WITH THEM, AND THE TEST NOW KNOWS WHY.

A .wgt sits on the panel until somebody updates it. Cutting 1.9.31 put nothing
on any screen, so an un-updated Tizen panel still has the broken handler and
moving its baseline would resurrect the dead slider on real hardware.

The test could not express that. It judged every family against "shipped
source", resolved as the newest tag — which is HEAD on a release commit, so
tagging 1.9.31 flipped all four biconditionals at once and demanded a baseline
change for displays that cannot have the fix yet. Green tree, red build, naming
a baseline, with nothing in the diff to explain it. main would have gone red on
the next commit whatever it contained; #242 just got there first.

So the two families are now modelled separately. Server-served: judged against
the working tree, both directions, because both are decidable from the build we
are about to serve. Device artifact: judged against the previous release, and
only in the over-claim direction — "the baseline claims it, so the shipped
player had better implement it" is always true and worth failing on, while
"HEAD gained the handler, so add it" is a guess about how many panels have
updated. The cost is that a stale entry can outlive the artifact reaching the
fleet; that is a judgement about screens, so a person makes it in
player-capabilities.js and records why.

player-capabilities.test.js carried the same stale reasoning hardcoded, and
docs/player-parity.md stated the old facts in four places — a parity matrix
that lies being the exact failure this whole model exists to stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 22:54:45 -05:00
ScreenTinker bf9ad16486 The checkpointer startup line no longer describes a policy it stopped having
It still read "escalate >16MB or 3 growing runs" after #240 added a size floor
and a cooldown to that second rule. Growth-across-three-runs on its own is
exactly the half that no longer holds, so the line described a checkpointer
that does not exist — and it is the line an operator reads to learn the policy.
During an incident it would send you hunting for a blocking checkpoint that the
new gates had in fact suppressed.

  [wal-checkpoint] off-thread checkpointer started (PASSIVE every 15000ms;
  blocking TRUNCATE when the WAL exceeds 16MB, or after 3 growing runs but only
  at >=8MB and at most once per 300s; respawn max 5/60000ms)

A test now asserts the line reports every knob that governs the decision, since
nothing else keeps a log string and the rule it describes in step. Writing it
caught its own bug first: anchoring the slice back to `return worker;` matched
the idempotence guard at the top of startWalCheckpointer(), not the log below
it, so the window was empty and every assertion passed vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 22:12:01 -05:00
ScreenTinker 70b2227fa8 chore(release): v1.9.31 2026-08-06 21:08:14 -05:00
ScreenTinker b29e3b4676 Judge the baselines against the PREVIOUS release, not the newest tag
Cutting 1.9.31 turned a green tree red, and the failing assertion named a
baseline rather than the tag that caused it.

"Shipped source" was resolved as the newest v* tag. That is wrong at exactly
one moment, and it is a moment that arrives at every release: on the release
commit the newest tag IS HEAD, so shipped source becomes the working tree,
every biconditional inverts, and the build demands BASELINE.web gain
audio.volume — for displays that cannot have the fix until this very release
reaches them. Tagging a release should not be able to change what the release
is allowed to contain.

A baseline describes an UN-UPDATED display, so the source it is judged against
is the release BEFORE the one being cut. Skip any tag pointing at HEAD and use
its predecessor: v1.9.30 here, and the newest tag as before during ordinary
development.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 21:08:14 -05:00
screentinker fc1331337f
Merge pull request #241 from screentinker/fix/240-checkpoint-stall-and-lag-telemetry
#240: stop the morning wave buying itself a blocking checkpoint
2026-08-06 20:59:16 -05:00
ScreenTinker 89cdd1052a CI: give the parity baselines the tags they judge against
main has been red since 4f7b4e3 for a reason visible nowhere in its diff.

The player-parity baselines describe what an UN-UPDATED display can do, so
they are judged against the SHIPPED source — `git show <latest tag>:…` —
rather than the working tree. actions/checkout defaults to a shallow clone
with no tags, so that lookup found nothing and the suite fell back to the
working tree. In a tree where today's QA had just fixed the players'
set_volume payload bug, the biconditional then demanded that
BASELINE.web/tizen gain audio.volume — for displays that cannot possibly
have the fix yet. Green locally, red in CI, and the failing assertion names
a baseline rather than the checkout that caused it.

So fetch the tags in the test job, and make the biconditionals SKIP when
there are none instead of asserting against the wrong source. That fallback
was never a slightly-early assertion; it was an inverted one. A skipped
assertion announces itself, a wrong one does not.

Verified both ways: with tags 15/15 pass, in a tagless shallow clone 13
pass + 2 skip + 0 fail (previously 1 fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 20:54:47 -05:00
ScreenTinker 59489b3b20 #240: stop the morning wave buying itself a blocking checkpoint
Bold reported loop lag that grew with uptime and reset on restart. The
signature they saw — mean = p50 = p99 = max, identical to two decimals —
is not a fixed cost paid on every cycle. It is what an IntervalHistogram
window reports when it recorded exactly ONE delay: the mean is the raw
value, and every percentile returns the bucket ceiling above it.
Reproduced against their exact numbers (1329.07 / 1329.59). So the loop
took one long turn that swallowed the sampling second, episodically —
which is what they later confirmed independently.

The turn is ours, and it is now measured rather than theorised. Probing
the real worker against a real WAL with one reader mid-transaction: a
single main-thread write blocked for 4,936ms behind the worker's
wal_checkpoint(TRUNCATE), which then reported WAL 8.8MB -> 8.8MB. TRUNCATE
is the blocking form and its locks are held ACROSS connections, so moving
it to a worker kept the fsync off the loop but not the lock; and it does
not throw when it cannot get those locks, it returns busy=1 having sat on
SQLite's 5s busy timeout and reclaimed nothing. Five seconds of stalled
loop for zero benefit, and silent.

It was reached far too easily. The rule was "escalate if the WAL grew
across three consecutive 15s runs" — which any sustained 45-second write
burst satisfies. A customer's fleet powering on in the morning does it
daily.

Two gates, because either alone leaves the hole open. A size FLOOR, so a
WAL in the lower half of its budget can't buy a blocking checkpoint it has
nothing to reclaim from. And a COOLDOWN, because the floor alone fixes
nothing for Bold — their WAL already sits at 6.2MB against a 16MB
high-water, above any sane floor, so every burst would still escalate.
However long the pressure lasts, our own maintenance may now stall the
loop at most once per window. The high-water rule bypasses both and is
untouched: a runaway WAL is the one case worth blocking for, so the
"WAL cannot grow forever" invariant is exactly as strong as before.

A busy TRUNCATE now says so in the log instead of reading like a success.

Also softened the adjacent path: when the worker is declared
unrecoverable, engageFallback() re-arms inline autocheckpoint on the main
connection — a state that is STICKY for the life of the process, i.e.
exactly the shape of "degrades with uptime, a restart fixes it". It used
to also run an unconditional main-thread TRUNCATE on the way in; that now
happens only when the WAL is genuinely over high-water, and the fallback
state is served on /api/status rather than being inferable only from a log
line that may have rolled.

Telemetry, so the next report is self-explanatory: loop_lag carries
`samples` (~50 when healthy, 1 when a single turn swallowed the second),
`tick_gap_ms` measured on the WALL CLOCK independently of the histogram,
and `worst_tick_gap_ms`/`worst_tick_at` — monotone, so five-minute polling
can no longer miss an episode.

Band semantics are deliberately unchanged. A one-sample window during a
real stall is the correct trigger for the shed valve; suppressing it would
blind the protection at exactly the moment it is needed.

Separately, device_telemetry gets the age sweep it never had. The
per-heartbeat row cap only ever trims the device whose heartbeat is being
handled, so a device that STOPS reporting leaves its rows behind forever.
The new sweep is per-device (rides idx_telemetry_device rather than
scanning), chunked and yielding like the device_status_log one, and
defaults to 30 days to match the uptime report's own default window — so
it cannot remove rows that report would have shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 20:22:21 -05:00
ScreenTinker 60deefd992 BrightSign: ask the volumes for their size instead of trusting the mount check
GetStorageStatus() is documented for SD:/SSD:/USB: only, so it can never confirm
internal flash, and roStorageHotplug may be absent entirely. Gating the probe on
it made 'cannot say' read as 'no disk': a player with an NVMe reported 1025 MB,
which is the widget's cache quota arriving through the page-side fallback.

roStorageInfo is asked directly as a second pass, with the mount check kept first
so a removable volume still wins over internal flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 18:40:12 -05:00
ScreenTinker b4363d8d26 Keep display.power on the Android baseline
screen_off blanks a fielded panel for real (owner/admin FORCE_LOCK, else the
accessibility lock); screen_on is a logged no-op. One capability renders both
dashboard buttons, so withholding the pair to hide the dead ON button also takes
blank-at-night — the half that gets scheduled — away from every panel that has
not updated. Panels that have updated declare for themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 18:10:52 -05:00
ScreenTinker 4f7b4e3989 Judge capability baselines against the SHIPPED source, not the working tree
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Two tests disagreed after the QA merges, and both were right about their own
half — which is what made the disagreement worth resolving rather than silencing.

A baseline describes what an UN-UPDATED display can do. The baselines were
justified against `git show v1.9.28:<source>` and then asserted against the
working tree, so the moment a player's payload bug was fixed the biconditional
demanded a baseline change for displays that cannot possibly have the fix yet. A
baseline entry moves when a fix SHIPS. It now reads the newest release tag, and
falls back to the tree when tags are unavailable (a shallow CI clone), because a
missing tag is a worse reason to fail a build than a slightly-early assertion.

While fixing it the helper threw a ReferenceError — the require was missing — and
its own broad catch swallowed it and quietly compared against the working tree
anyway. The catch now rethrows ReferenceError and TypeError. A fallback that
hides a programming error is the same failure shape as everything else this QA
pass found.

The BrightSign assertion encoded the older, more generous baseline: reboot needs
the BrightScript host bridge, and an undeclared unit is precisely the one we
cannot know has it. What that test is really pinning is that the row still
classifies as brightsign rather than decaying to `web` — so it now asserts that,
plus the video playback that is genuinely safe to assume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 18:01:24 -05:00
ScreenTinker ac2389716c Merge QA: make the parity matrix and the capability baselines true
# Conflicts:
#	server/lib/player-capabilities.js
2026-08-06 17:47:28 -05:00
ScreenTinker c778f050a9 Merge QA: gate the ungated device commands, and stop a register erasing a panel's platform
# Conflicts:
#	server/server.js
2026-08-06 17:46:46 -05:00
ScreenTinker dc056a5a59 Merge QA: Tizen storage that actually works, and BrightSign APIs that exist 2026-08-06 17:46:22 -05:00
ScreenTinker 3b7cd67ef5 Merge QA: working volume on browser players, and mute is no longer collateral
# Conflicts:
#	server/player/sw.js
#	server/test/player-sw-scope.test.js
2026-08-06 17:46:15 -05:00
ScreenTinker 32189d1784 Merge QA: a fresh panel no longer skips its first item; rotated wall screenshots 2026-08-06 17:45:42 -05:00
ScreenTinker d7ee971543 Merge 1.9.30: fail loudly on a missing asset; stop an empty playlist wiping the cache
# Conflicts:
#	CHANGELOG.md
2026-08-06 17:45:34 -05:00
ScreenTinker 9a630087ff Show the video in a rotated wall panel's screenshot, not a black rectangle
#236 gave each video-wall panel a mounting rotation, which for the first time
puts a real rotation on an ancestor of the ExoPlayer TextureView. The screenshot
compositor could not express that: it pasted the video frame with an axis-aligned
Rect built from getLocationInWindow(), so on a rotated panel the frame landed
outside the capture bitmap entirely. What reached the dashboard was the plain
black that view.draw() leaves wherever a TextureView is — a panel that looks dead
while it is playing perfectly, which is the worst thing a diagnostic can say.

The frame is now placed through the same transform chain the hierarchy was drawn
with, accumulated up the parent chain the way the framework does when it draws a
child, so any ancestor rotation/translation is honoured. The bitmap is also
scaled from the surface's own dimensions rather than assumed to match the view.

Measured on the emulator, a wall panel playing video, remote screenshot vs the
adb framebuffer at the same moment (standard deviation — 0 means a flat frame):

                    before                  after
  rotation 0    sd 0.439 / truth 0.430   sd 0.443 / truth 0.435   (unchanged)
  rotation 90   sd 0     / truth 0.461   sd 0.448 / truth 0.448
  rotation 90   sd 0     / truth 0.467   sd 0.460 / truth 0.457
  rotation 90   sd 0     / truth 0.408   sd 0.463 / truth 0.466

Every rotated capture was #010101 with zero variance before; each now tracks the
real framebuffer. Rotation 0 is unchanged, and so is the ordinary fullscreen
(non-wall) path, re-measured across images and video.

No unit test: this is android.graphics.Matrix semantics against a live view
hierarchy, which the JVM test source set cannot exercise — the evidence is the
before/after measurement above. Android 151/151, server 1298/1298.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:40:48 -05:00
ScreenTinker e313826d85 chore(release): v1.9.30 2026-08-06 16:39:48 -05:00
ScreenTinker e812f35b6b Fail loudly on a missing asset, and stop an empty playlist wiping the cache
Two faults that are live on 1.9.29, both silent, both ending in a dark screen.

A missing upload answered 200 OK with Content-Type: text/html and 15KB of the
dashboard, under the immutable/30-day header the mount sets before it knows
whether the file exists. Every downloader here treats 200 as success, so a panel
stores the page AS the video and caches it for a month; Android validates the
byte count, not the type, so a correctly-sized page passes integrity and is
promoted as a valid asset. Reachable exactly when it hurts — a replace writes a
new random filename and unlinks the old one. Now a 404, with the cache header
removed.

And the service worker treated an empty playlist as "keep nothing". But
`assignments: []` is what the server sends for a device between playlists, for a
playlist never published, and from the catch when a snapshot fails to parse — so
a message that means nothing of the sort deleted every byte of media the panel
held. Only survivable while the uplink is up, i.e. exactly when the cache is
worthless.

Both regression tests drive the whole server or the real worker, because both
bugs live in the relationship between two pieces that are individually correct:
the order of two mounts, and the difference between "needs nothing" and "did not
arrive".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:31:53 -05:00
ScreenTinker a1aeb324d7 Stop the worker claiming credit for offline widgets it never sees
sw.js said its cache-first widget branch "is what lets a widget keep rendering
when the network is gone". It is not. The player mounts widgets in an iframe
sandboxed to `allow-scripts` with no allow-same-origin, so the frame is an
opaque-origin client, and a service worker does not control those — the
navigation never reaches the handler.

Measured rather than reasoned: a clock widget mounted five times over 25 seconds
of real playback in Chrome while the shell cache held zero widget entries, and a
plain fetch() of the identical URL from the controlled page was intercepted and
stored on the first try. The branch works; the player's own widgets are simply
not what reaches it.

What actually holds widgets through an outage today is the HTTP cache plus the
server's `max-age=31536000, immutable` on a rev-pinned render. That is sound in
a desktop browser and is exactly the store this module's own header says is NOT
persistent on BrightSign, which is why content caching had to exist at all. So
the comment now records the limit and names the two ways out — route the render
through a same-origin fetch and mount it as srcdoc, or grant allow-same-origin
and hand widget scripts the player's origin, which is not a trade worth making
for an offline nicety.

The test pins the security property so nobody buys the cache with it, and pins
the Cache-Control header, which is now known to be load-bearing on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:26:43 -05:00
ScreenTinker 5d56e538af Play the first item of a playlist on a fresh panel, instead of skipping it
A newly paired panel always learns its playlist BEFORE the media arrives, so
start() finds nothing playable and the 3-second content re-check is what really
begins playback. updatePlaylist() has already seeded currentIndex = 0 for a
playlist that has not started, but the re-check advanced PAST that index — so
the first pass ran 1,2,3,0 and item 1 only appeared after the list wrapped.

On the emulator, a fresh pair with a 4-item playlist reproduced it every time:

  Starting playback
  Playing: red.png (index 1)      <- clip32.mp4 (index 0) never got its turn
  Playing: clip7.mp4 (index 2)
  Playing: blue.png (index 3)
  Playing: clip32.mp4 (index 0)   <- 54s late, on the second pass

On a two-item playlist that is indistinguishable from "only one of the two ever
plays", which is how it was reported.

The distinction the re-check was missing is hasContentOnScreen. With content up,
currentIndex is a real position that has had its turn and the scan must move past
it. With nothing up, currentIndex is only where playback INTENDED to start, so
skipping it drops that item. PlaylistSelection.recheckIndex now makes that choice
explicitly, and playableFromIndex treats a negative index as "no position yet"
rather than wrapping onto the last item.

Verified on the emulator against the same cold start: the first pass is now
0,1,2,3,4 in order. Playback resume (#234) is untouched — it never reaches the
re-check when its target is cached, confirmed by an Activity relaunch resuming
mid-playlist as before.

Tests: 6 new cases in PlaylistSelectionTest covering both sides of the rule, the
still-downloading item, the no-position-yet start, and the empty case.
Android 151/151, server 1298/1298.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:26:34 -05:00
ScreenTinker 3d9ef039e5 Make the volume slider real, and stop an empty playlist wiping the cache
Three faults in the web player, each found by driving the shipped code in a
browser against the real server rather than by reading it.

set_volume did nothing at all. The dashboard sends `{ level: 0..1 }`
(device-detail.js: slider/100) and the Android player reads exactly that; this
player read `payload.value` and divided it by 100. Nothing in the product sends
`value`, so every browser panel acked the command and ignored it — the quietest
possible failure. Correcting only the key would have been worse than leaving it
broken: `level: 0.5` would have become 0.5%, which is inaudible and looks fixed.
The fraction is now canonical, `value` is still read as a percentage for
anything written against the old handler, and the scale is chosen by WHICH KEY
arrived rather than by the size of the number — 1 is legal in both conventions,
so a magnitude guess is guaranteed to be wrong for somebody. Parsing moved into
volumeLevelFromCommand() so it can be asserted without a socket.

setMediaVolume() also wrote `el.muted = (v === 0)`, so any non-zero volume
un-muted whatever was playing. An item an operator had deliberately silenced
started making noise the moment anyone touched the slider — reproduced live:
item flagged muted, one set_volume, muted went false. Mute has four inputs and
a fixed order (lib/media-mute.js), it is resolved when the element is mounted,
and a level is not entitled to overrule it — least of all the autoplay rule,
where unmuting without a gesture costs the video rather than winning the audio.
Volume 0 is silence on its own.

And the service worker pruned its content cache to an EMPTY keep-set.
`assignments: []` is what the server sends for a device between playlists, for a
playlist never published, and inside the `catch` when a published_snapshot fails
to parse — none of which mean "delete the media". Reproduced: three cached
assets, one empty payload, cache emptied. That is only survivable while the
uplink is up, which is precisely when the offline cache is worthless. A cache
kept too long costs disk the quota reclaims anyway.

Verified in Chrome against a live server: volume 0.42/0.8/0/0.25 land on the
element and survive an item change, a muted item stays muted through a volume
command, and three cached assets survive an empty push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:18:25 -05:00
ScreenTinker 3e37d33b80 QA: close four ways a control or an asset lied about itself
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.

1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
   miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
   15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
   sets before it knows the file exists. Every player downloader treats 200 as success,
   so a panel stores the HTML page AS the video and caches it for a month, rendering a
   black frame with nothing in any log. Reachable exactly when it hurts: a content
   replace writes a new random filename and unlinks the old one. The mount now
   terminates a miss with a 404 and drops the cache header.

2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
   has always refused a command the panel cannot honour, and the comment above it is right
   about why ("hiding the button is not enforcement — this socket is reachable directly").
   Every word applied to the four handlers immediately above it, which had none: a display
   declaring [] still received screenshot-request, remote-touch, remote-key and
   remote-start. Measured, not inferred. They now refuse on remote.screenshot /
   remote.input / remote.stream and name the capability in the ack; remote-stop stays
   ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
   absent declaration still resolves to its platform baseline and keeps everything.

   The wall panel list (#235) made this visible: it offered a Screenshot button for every
   panel, including a BrightSign, which has no screenshot capability at all, and popped a
   toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
   capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
   as "pre-capability server, show everything" — wrong in the one case that matters), so
   the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
   Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
   commands on display.power; both now agree.

3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
   field to the literal 'unknown' and persistIdentity wrote it straight over. That column
   is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
   a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
   for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
   power and reboot and gained screenshots it cannot take. platform and client_type are
   now preserved (physical facts); client_version and contract_version still decay, because
   there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
   second signal for a Tizen TV.

4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
   a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
   brand-new "default an item to the clip's own length" then handed out the wrong number
   for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
   image measured it with raw sharp metadata and thumbnailed without .rotate(),
   re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
   share lib/content-ingest.deriveMediaMetadata.

Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.

Full suite green (1319).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:12:29 -05:00
ScreenTinker 4e1de8ec0e Make the Tizen and BrightSign players do what they say they do
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.

TIZEN

The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.

Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.

Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.

The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.

BRIGHTSIGN

FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.

The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.

offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.

display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.

roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.

dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.

Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.

TESTS

The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 10:51:15 -05:00
ScreenTinker c1270599c3 Make the parity matrix true, and stop three controls that do nothing
The parity doc and the capability model had drifted from the players in both
directions, and nothing failed when they did. Auditing all four players against
their shipped sources turned up three controls a customer can press today that
change nothing, and a set of baselines that were partly too generous and partly
too stingy.

The three dead controls:

  - The volume slider works on Android only. The dashboard sends set_volume as
    { level: 0..1 }; the web player reads payload.value and Tizen reads
    payload.value ?? payload.volume, so on both the number is undefined and the
    handler quietly declines. Three complete, working volume implementations
    that cannot be driven. The fix is one line in each player and belongs to
    those files; audio.volume is out of the web and brightsign baselines until
    it lands, held there by a biconditional test that fails the moment a player
    starts reading `level`.

  - Every #161 Tier-2 command was refused for the entire fleet. lock_now,
    power_menu, status_bar, block_uninstall and unblock_uninstall were gated on
    system.device_owner, which no player declares and no baseline grants, so
    supports() was false everywhere -- including on the device-owner panels the
    feature was built for. The dashboard still drew the buttons because it also
    gates on device.tier === 2. Fixed here: those five now accept
    system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares
    under `if (isOwner)` and nothing else, and which no non-Android player
    declares. Android should declare system.device_owner and retire the
    stand-in.

  - enable_system_capture required the capability it creates. It raises the
    MediaProjection consent dialog -- the way a panel GAINS capture -- and was
    gated on remote.screenshot, so the only panel that needs it was the one
    panel that could not be sent it. Now ungated. The dashboard still hides the
    button behind the same check; that half is a frontend change.

The baselines describe what an un-updated fielded display can do, and since
v1.9.29 is the first build in which any player declares anything, that means
v1.9.28. Every entry is now justified against `git show v1.9.28:<source>`:

  - android loses display.power (v1.9.28 answers screen_on with a logged no-op,
    so the ON half is dead on every fielded panel and one capability renders
    both buttons) and system.reboot (owner-only; off-owner it paints an
    accessibility power dialog over the signage). Scheduled reboots now skip
    undeclared Android panels rather than logging a reboot that never happened,
    which is the reason that gate exists.
  - tizen gains display.power: v1.9.28 implements both halves with no signing
    and no panel API, so withholding it hid a working control.
  - brightsign loses audio.volume, display.power, system.reboot,
    system.restart_player and offline.cache. All need a host bridge the unit is
    not known to have, and restart_player without one is the page reload that
    darkened a panel on 2026-07-28.

Also found, not fixed here because the files belong to others:
st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities()
-- and its 199 lines of passing tests constrain nothing a BrightSign actually
declares; the two disagree on six capabilities and the bridge is right about
most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt
under-declares display.brightness.

The new test reads the player sources rather than the table: a dead-button rule
(every gated command has a branch somewhere), an unreachable-capability rule
(which would have caught system.device_owner), and biconditionals so a fix in a
player fails the test until the baseline follows. Claims that need hardware --
CEC reaching a display, a widget being allowed a service worker, SyncManager
holding frame lock -- are marked unverifiable in the document instead of
asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 10:24:10 -05:00
ScreenTinker 2237edab12 Merge #236/#235: portrait video walls, and a wall status view
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-06 09:52:20 -05:00
ScreenTinker 4d86a75196 Merge #239: let the playlist preview skip to any item
# Conflicts:
#	frontend/js/views/playlists.js
2026-08-06 09:52:20 -05:00
ScreenTinker 97f53a5b72 Merge #238: preview a rotated display the way the wall shows it 2026-08-06 09:50:27 -05:00
ScreenTinker 63b9de1329 Merge #237: default a playlist item's duration to the video's own length 2026-08-06 09:50:22 -05:00
Claude e4c25c39df Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as
the audience sees it. Invisible while every panel is the normal way up, and
actively misleading the moment one isn't — two portrait-mounted panels standing
side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy
of every video, before the output came out right. It worked, but only after
trial and error, and it meant a portrait wall could never reuse content as-is.

Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same
convention as the per-device orientation setting), the canvas means the physical
wall, and the player works out the mapping. The geometry lives in one place,
server/lib/wall-geometry.js, because four players have to agree on it to the
pixel across a seam.

Existing walls need no migration and do not move. Every wall in the field is
rotation 0, and that case takes the original expression verbatim on all three
players rather than the algebraically-equal centre-based one — the two differ in
the last float bit, and a float's worth of disagreement between two panels is a
hairline seam down a wall that was aligned yesterday. Pinned by the first test
in wall-geometry.test.js and by wall-payload.test.js.

While a display is in a wall its panel rotation replaces its own orientation:
both describe the same physical fact, so honouring both turned the content twice.

#235: a wall replaced its members' cards, so one dead panel of a four-panel wall
was invisible from the dashboard, and inspecting a single screen meant pulling it
out of the live wall and putting it back. The wall screen now lists its panels
with live online state, a per-panel screenshot request, and a link to each
device's page; the dashboard wall card carries per-member status chips that track
socket updates.

Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts
each panel's viewport maps onto exactly its own rect of wall space, for every
rotation, plus a mixed wall and the Tizen player's hand-ported copy executed
against the canonical rule. Full server suite green (1260).

Not verified here: the Android and Tizen renders on real hardware. Kotlin
compiles clean; the maths is shared/tested, the view plumbing is not.
2026-08-06 09:46:31 -05:00
ScreenTinker 52ab04204a Preview a rotated display the way people see it, not the way its framebuffer is
#238: the dashboard preview of a 90/270 display was sideways while the panel on the
wall was right — the split that makes a preview useless, because a designer checking
portrait content can no longer tell a real fault from an artefact of the tool.

A portrait panel is a landscape framebuffer that the player rotates content INSIDE
(+90), hung turned the other way (-90); the two cancel and the viewer sees upright
portrait. The dashboard modelled only the first half. It iframed the player into a
box it had already given the finished 9/16 shape, so the player rotated a second time
inside a box that was pretending to be the finished picture, and nothing anywhere
stood in for the mount. Screenshots had the opposite half missing: they are the raw
framebuffer, shown untouched, so every portrait screen looked wrong on the cards and
in Now Playing too.

So each surface now has a stage (the panel's face) and a frame (its framebuffer),
with the frame turned by the INVERSE of the player's angle. Turning it the same way
is the tempting mistake and the worst kind of wrong: 90+90 lands upside-down, which
reads as nearly-right. The dimension swap is not cosmetic either — composing into the
real framebuffer shape is what makes the player lay content out in the same portrait
box the panel uses; hand it a portrait viewport instead and every zone and object-fit
decision is computed for a canvas no panel has.

The geometry is the players' own rule (server/lib/orientation-style.js), served to the
dashboard rather than re-derived, since a second copy of a rotation rule is exactly how
the two came to disagree. Covers the device preview modal, the playlist preview's
portrait toggle (same fault), Now Playing and the device cards. The Remote canvas stays
raw on purpose: taps are sent as fractions of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:38:31 -05:00
ScreenTinker 6471c503ab Default a video playlist item to the clip's own length (#237)
Adding a 32s video gave it the flat 10s default, so it was cut off mid-play
unless the operator looked up the runtime and typed it — per item, every time.
The content row already carries the probed duration; it now becomes the default.

The rule lives in one place (lib/item-duration.js) because the operator sees one
product, not six insert paths: playlist add, assign-to-display, group assign,
agency portal, content-only schedule, and the public API all share it. Only the
playlist route defaulted before, and it stored the raw probe (31.7) which the
Android player's optInt read silently truncated back to 31.

Explicit values always win. Content with no trustworthy duration (image, widget,
YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is
0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than
reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops
and black-screens the TV.

Dashboard: the add-item picker shows a clip's length, the assign-to-display modal
pre-fills the duration field from the selected clip (never overwriting a value the
operator typed), and onboarding stops hardcoding 10 on the first assignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:36:53 -05:00
ScreenTinker aa77332c0d Let the playlist preview skip, so reviewing item 8 does not cost seven durations
The preview shipped without the skip control #104 asked for, so checking a late item meant
watching every item before it in real time — the thing operators do most when ordering a
playlist with a client on the phone.

The preview is already the real player in device-free mode (an iframe of /player?preview=1),
so this drives that instance rather than growing a second playback implementation: the
dashboard posts next/prev to the one contentWindow, the player steps its own currentIndex and
re-renders through the same path a natural advance uses, and posts back index/total so the
modal can say "3 of 7".

Nothing here can reach a live screen. A real display is driven over its server socket and holds
no window handle this page could address; the message listener is installed only by the preview
boot path, previewNavigate refuses outside PREVIEW_MODE, and both ends pin the origin.

Stepping is schedule-aware in the direction of travel — falling forward past a dayparted item
would make "previous" walk forwards — and a multi-zone playlist reports itself as such, because
all zones play at once and a counter there would be a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:35:16 -05:00
ScreenTinker afe3f7f57f Retry the ghcr push once — a transient 403 should not cost a release
ghcr refused the 1.9.29 push with "denied: permission_denied: Error from
intermediary with HTTP status code 403", then accepted the identical build on a
manual re-run minutes later. Nothing about the token, the permissions or the
workflow changed in between; the registry simply said no once.

The timing is what makes it worth handling. The GitHub Release job has already
published by the time this runs, so a failure here leaves a tag that exists with
no image behind it — alpha and every self-hoster pulling :latest see a version
that is announced and unpullable, which reads as a broken release rather than a
hiccup at a registry. It also needs a human to notice and re-run, which is the
part that does not scale.

One retry, after a pause, and a second refusal still fails the release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:10:17 -05:00
ScreenTinker c2240288a7 Serve the service worker from the root, so its scope needs no header to survive
Found deploying 1.9.29 to production. A worker's scope defaults to its own
directory, so /player/sw.js could only control /player/ and below; the fix was to
request a wider scope and permit it with Service-Worker-Allowed. That works right
up until something between the origin and the browser does not pass the header
on. Cloudflare served a CACHED response for that path across the deploy —
headers and all — and the registration failed outright.

A rejected registration is worse than a narrow one: the player runs with no
worker at all, on every URL, and nothing about it is visible from the server. The
origin was sending the header correctly the whole time; a cache-busted request
proved it. It self-heals when the edge entry expires, which is precisely the kind
of fix nobody should have to know about.

Served from /, the default scope is already the whole origin and no header has to
survive the trip — through Cloudflare, through whatever a self-hoster puts in
front of it, or through a corporate proxy we will never see. /player/sw.js keeps
serving for players still asking for it, and the header is still sent where it
does survive.

Verified in a real browser: all three of /player, /player/ and /player/index.html
are controlled from root scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:05:06 -05:00
ScreenTinker 3b9ad08454 chore(release): v1.9.29 2026-08-06 08:39:26 -05:00
ScreenTinker 996b0ab6c0 docs: 1.9.29 changelog 2026-08-06 08:39:25 -05:00
ScreenTinker db8846a139 BrightSign: report what the host knows, through the channels the other players use
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
A BrightSign could see things the page cannot ask for — the uptime, the wired IP,
the video mode in force, which volume it booted from, whether a staged package
applied — and it printed all of it to a serial console. On a panel on a wall that
is the same as reporting nothing.

The cost was concrete and recent. A single bad string literal stopped the host
script compiling; the only evidence anywhere was one line on a cable, and from
the server the display looked identical to one that had never started. Diagnosing
it needed someone physically present with a serial adapter. Every other player
reports its own failures.

Three hops, each thin: the host posts, the bridge carries, the player emits on
the channels it already uses (device:log, device:event, and the telemetry the
heartbeat has carried for releases).

The pre-widget phase is the part that matters and the part that was hardest to
reach — the storage probe, a pending package being applied, the video mode being
set, all happen before there is a page to talk to. Those lines accumulate in a
buffer and flush the moment the widget exists, so the boot story arrives even
though it happened before anyone could listen. BrightScript has no global store
here (no GetGlobalAA), so the buffer is threaded explicitly; losing the boot
entirely was the worse option.

Two things become incidents rather than console lines: the watchdog rebuilding a
wedged widget, which is the most important thing a player does unattended and
previously healed in silence — a panel rebuilding itself every two minutes looked
exactly like a healthy one — and a load-error, which now names the resource that
failed. Both use event types the server actually accepts; an invented one is
dropped silently and would have been just as invisible.

Host telemetry merges into the existing snapshot rather than opening a channel,
and the host's numbers win where they overlap: navigator.storage.estimate()
describes the widget's cache quota, not the disk, so a panel can report gigabytes
free while the volume holding them is full.

Two API traps caught in my own new code before it shipped, both the same shape as
the ones being fixed: Str() applied to a value already documented as a String
(it is for numbers, and would abort the event loop while reporting a diagnostic),
and Stri() handed a float from an inline division. The checker now pins the first.

Verified on the XT245: boots clean, plays, online. The bridge and player halves
are served BY the server, so they take effect on the next deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 00:02:33 -05:00
ScreenTinker c2effc9f5f BrightSign: use the platform's own file-existence idiom, and don't re-fetch a staged package
Both found while watching a real self-update run end to end on the XT245.

FileExists now uses roReadFile + type(), which is what BrightSign's own published
autozip.brs does (their CheckFile). MatchFiles is for LISTING a directory; as an
existence check it has already burned this codebase once, passing a full path as
both arguments so it could never return true for anything. Correcting it to a
directory plus a bare name did work — I misread a mid-cycle inspection as a
second failure and it was not — but roReadFile takes the full path every call
site naturally has, needs no reasoning about volume-root semantics, and is the
form the vendor ships. The narrower idiom is worth having here precisely because
nothing in CI can tell us when this is wrong.

CheckPackageUpdate now returns early when a package is already staged. Observed
on hardware: the periodic check fired in the gap between staging an archive and
the reboot that applies it, and pulled the whole thing down a second time.
Harmless on a desk; on a metered or marginal link it is exactly the waste the
rest of this release exists to remove.

The self-update chain is now proven on hardware, twice: check, download, sha256
and size verify, stage, reboot, staged unpack, move into place without touching
screentinker.json, mark done, reboot into it. The player reports 1.9.29-rc5 and
its autorun.brs carries the archive's timestamp rather than a hand-copied one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 23:22:09 -05:00
ScreenTinker a85e067260 chore(release): v1.9.29-rc5 2026-08-05 23:00:59 -05:00
ScreenTinker c226bdddc6 docs: 1.9.29-rc5 changelog 2026-08-05 23:00:58 -05:00
ScreenTinker 647a2bcc56 BrightSign: replace the Roku APIs, and a literal that stopped the script loading
The host scripts were written against the wrong reference. BrightScript is
Roku's language, the two API references read almost identically, and nothing
here can run either — so a call to an object that does not exist looked exactly
like a call to one that does. Verified on an XT245 and against BrightSign's
published reference; every item below was confirmed, not guessed.

THE ONE THAT COST A BOOT. `body$ = "{""width"":"` is not an escaped quote —
BrightScript has no escape sequences, so that is three adjacent literals with no
operator, and the compiler rejects the WHOLE FILE:

    ScriptLoadError: Syntax Error. (compile error &h02) in SSD:/autorun.brs(196)

Not a broken feature — no player at all, on a display showing nothing. Built
with Chr(34) now.

THE ONE IN THE FIELD. MatchFiles takes a DIRECTORY plus a pattern and returns
nothing when the pattern contains a separator; we passed a full path as both
arguments. FileExists() could never return true, for any file, on any player.
That is exactly what a consultant hit: "[st-autozip] no autorun.zip on any
volume" printed while `dir SD:` listed autorun.zip. It also silently disabled
the entire self-update path. (Related: `autorun.zip_invalid` on his card is not
an accusation — it is the rename BrightSign's own example performs AFTER a
successful unpack. Our STORED-only insistence fixed a problem that was never
there; deflate32 is supported.)

Roku objects that do not exist here, each of which disabled a feature quietly:
roFileSystem (~20 sites — the update path could never mark a package applied),
roMessageDigest (verification returned false unconditionally and burned an
attempt counter), PostFromStringWithRetry (a snapshot request raised "member
function not found" from inside the event loop and took the player down).
Replaced with MoveFile/DeleteFile, roHashGenerator, and an async POST on a
message port, which is the only documented way to read a POST body.

Unpack() returns Void, so `if not package.Unpack(...)` was a type error dressed
as an error check; success is now proven by looking for the extracted file. And
Unpack() DELETES everything already in its target — unpacking an update to the
volume root would have erased the player's provisioning and its whole content
pool as a side effect of a routine upgrade. It stages to a directory of its own
and moves files into place, deliberately never overwriting screentinker.json.

Also: SetMode() takes one argument (rotation belongs to SetScreenModes, which
REBOOTS, so it only fires on a real change); GetStorageStatus is unreliable with
"USBn:"; a load-error names its resource in `uri`, not `url`.

server/test/brightscript-api-surface.test.js is the cheap thing that would have
caught all of it: a deny-list of Roku APIs plus the argument shapes and literal
forms that compile and then do nothing. It cannot prove the scripts are right;
it stops these specific mistakes coming back. It has already earned its keep —
it caught a comment I had broken while writing this change.

Verified on hardware: the player loads clean from the NVMe, restores its cached
playlist and plays BEFORE the server connects, fetches media with the new
?rev= revision, and registers against alpha rc4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 22:57:36 -05:00
ScreenTinker 76ba6c7506 BrightSign: resolve the storage root by probing, and put widget storage on it
Verified on the XT245 after fitting an NVMe.

StorageRoot() knew only FLASH and SD. That unit has a dead card slot and boots
from internal flash, so the moment real storage was fitted and the deployment
moved onto it, every derived path — the offline page, the widget's local
storage, the self-update paths — resolved to "SD:", a slot with nothing in it.
It now probes in the order the OS itself searches for an autorun script, so the
answer matches the volume the player actually booted from.

storage_path was "/cache", which carries no BrightSign drive specifier and so
resolves outside the writable volumes. It is now an absolute path on the boot
volume, confirmed on hardware: after the move the player created SSD:/cache
where before it only ever touched FLASH:/cache.

Note for anyone chasing the same thing: this did NOT enable the service worker.
The widget still never requests sw.js, so the player's inability to cache
offline on BrightSign is not a storage-configuration problem. The capability is
declared honestly now (see the previous commit) rather than advertised and unmet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 22:32:17 -05:00
ScreenTinker 0a888910dc Stop claiming offline cache on a runtime that refuses to run a service worker
Found on alpha after deploying rc4, by comparing what a device advertised
against what it actually requested.

A real BrightSign XT245 has navigator.serviceWorker, passes an
`'serviceWorker' in navigator` check, and then never even fetches sw.js — its
widget runtime refuses the registration. It was declaring offline.cache to the
fleet while unable to cache a single byte, which is precisely the lie the
capability model exists to prevent. The claim is now made on a worker that is
actually IN CONTROL, and a refused registration sets a flag so the negative
sticks on a runtime where it will never succeed.

That failure previously went to console.warn, on a display nobody has a console
for, so a panel that could cache nothing looked identical to one that could. It
now reports app_error/sw_unavailable — as an allow-listed event type, since an
unknown one is dropped by the server and would have been just as invisible.

The cost is that the first load under-reports, before the worker claims the
page. That is the right direction to be wrong in, and it self-corrects: the next
register sends the true set.

Also corrects docs/player-parity.md, which claimed BrightSign simply inherits
the web player's service worker. The failing unit runs BSN's Supervisor rather
than our brightsign/autorun.brs, and Supervisor's widget has no storage_path —
the setting our own host script does configure and the precondition for a widget
having persistent storage. So this is likely a widget config issue rather than a
platform limit, but it is UNVERIFIED on hardware and the doc now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 19:34:52 -05:00
183 changed files with 21954 additions and 1278 deletions

View file

@ -25,6 +25,15 @@ jobs:
working-directory: server
steps:
- uses: actions/checkout@v6
with:
# The player-parity baselines judge a claim against the SHIPPED source
# (`git show <latest tag>:…`), because a baseline describes what an
# UN-UPDATED display can do. The default shallow checkout has no tags, so
# the suite silently fell back to the working tree and the biconditionals
# inverted: fixing a player's payload bug made CI demand a baseline change
# for displays that cannot possibly have the fix yet. Green locally, red
# here, for a reason found nowhere in the diff.
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '20'
@ -77,6 +86,39 @@ jobs:
working-directory: android
run: ./gradlew :app:testDebugUnitTest --no-daemon
# Every artifact that can enter the APK must have a licence on file. This runs here
# rather than in its own job because the Gradle cache and Android SDK are already warm.
- name: Licence gate (APK runtime classpath)
run: node scripts/android-license-check.js
licenses:
name: Licence gate + SBOM (production deps)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: server/package-lock.json
# --omit=dev on purpose, and it is the whole point of the job. A developer checkout
# carries sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later; it is a test
# fixture generator that never reaches a server. Auditing anything other than a
# production install would report a licence we do not actually ship.
- name: Install production dependencies only
working-directory: server
run: npm ci --omit=dev
- name: Licence gate
run: node scripts/license-check.js --sbom sbom/screentinker-server.cdx.json
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom/
if-no-files-found: error
smoke:
name: Boot smoke + version check
runs-on: ubuntu-latest
@ -100,6 +142,12 @@ jobs:
working-directory: server
env:
SELF_HOSTED: 'true'
# Boot WITH the collector on. This block is config-gated and only the
# statistics-collecting deployment sets the flag, so it had never executed in CI,
# on alpha, or in any test - and a load-time crash inside it took production down
# while every check was green. Code only one deployment runs is exactly the code
# CI has to execute.
TELEMETRY_COLLECTOR: '1'
run: |
node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
echo $! > "$RUNNER_TEMP/server.pid"
@ -122,6 +170,21 @@ jobs:
test "$REPORTED" = "$EXPECTED"
echo "OK: status ok, version $REPORTED matches VERSION"
# Booting is not enough on its own - the collector could be mounted and broken. Prove
# the routes it adds actually answer, so a fault inside that block fails here rather
# than on the single deployment that turns it on.
- name: Assert the collector routes answer when enabled
run: |
STATS="$(curl -sf http://localhost:3001/api/public/stats)"
echo "stats: $STATS"
test "$(echo "$STATS" | jq -r 'has("screens") and has("installs")')" = "true"
REPORT="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' -d '{"bad":1}' \
http://localhost:3001/api/telemetry/report)"
echo "malformed report -> HTTP $REPORT"
test "$REPORT" = "400"
echo "OK: collector mounted and answering"
- name: Stop server
if: always()
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true

View file

@ -85,6 +85,15 @@ jobs:
./scripts/build-autorun-zip.sh -o autorun.zip
ls -la autorun.zip
# A published SBOM is what turns "we track licences" into something a customer or an
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
- name: Generate SBOM (production dependencies)
run: |
( cd server && npm ci --omit=dev )
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
ls -la screentinker-sbom-*.cdx.json
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
run: |
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
@ -100,15 +109,42 @@ jobs:
- name: Generate release notes
run: |
PREV="${{ steps.ver.outputs.prev }}"
VERSION="${{ steps.ver.outputs.version }}"
# Prefer the hand-written CHANGELOG section for this version.
#
# The generated list is commit SUBJECTS, which describe the work, not the release: cutting
# 1.9.34 produced a page reading "chore(release): v1.9.34" and one changelog commit, while
# the entry describing single sign-on, the native-dependency removal, the update fixes and
# every outside contributor sat in CHANGELOG.md and was never published. The notes on the
# release page are what most people actually read, so they should be the written ones.
#
# awk rather than sed: the body contains regex metacharacters and markdown that a sed range
# would mangle. This takes everything between `## <version>` and the next `## ` heading.
CHANGELOG_BODY="$(awk -v v="## $VERSION" '
$0 == v {found=1; next}
found && /^## / {exit}
found {print}
' CHANGELOG.md)"
{
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
echo "$CHANGELOG_BODY"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
# No entry for this version — fall back to commit subjects rather than publish a
# release with no notes at all. scripts/bump-version.sh already warns when the
# CHANGELOG has no matching heading; this is the same gap showing up downstream.
echo "_No CHANGELOG entry for $VERSION; listing commits instead._"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
fi
fi
echo
echo "### Artifacts"
@ -127,6 +163,7 @@ jobs:
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
} > RELEASE_NOTES.md
cat RELEASE_NOTES.md
@ -144,6 +181,7 @@ jobs:
--notes-file RELEASE_NOTES.md \
"${TARBALL}" \
autorun.zip \
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
tizen/ScreenTinker.wgt
docker:
@ -171,7 +209,33 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ghcr refused this push on the 1.9.29 release with "denied: permission_denied: Error from
# intermediary with HTTP status code 403", then accepted the identical build on a manual
# re-run minutes later. Nothing about the token, the permissions or the workflow changed in
# between — the registry simply said no once.
#
# That is worth one retry rather than a failed release, and it is worst exactly here: the
# GitHub Release job has already published by this point, so a failure leaves a tag that
# exists with no image behind it. Anyone deploying from ghcr — alpha, and every self-hoster
# pulling :latest — sees a version that is announced and unpullable, which reads as a broken
# release rather than a hiccup at a registry.
- uses: docker/build-push-action@v6
id: push
continue-on-error: true
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.ver.outputs.tags }}
- name: Pause before retrying the push
if: steps.push.outcome == 'failure'
run: sleep 45
# No continue-on-error: a second refusal is a real failure and must fail the release.
- name: Retry the push
if: steps.push.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64

4
.gitignore vendored
View file

@ -59,3 +59,7 @@ audit/
# Local SQLite artifacts (any extension the tooling might produce)
*.sqlite
*.sqlite3
# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset)
sbom/
*.cdx.json

View file

@ -1,5 +1,777 @@
# Changelog
## 1.9.36
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
### Fixed — 1.9.35 would not start on a server collecting install statistics
A server with install-statistics collection switched on could not start 1.9.35. It threw
`ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
and a service manager configured to restart it would do so in a loop.
**Almost nobody is affected.** The fault is inside a block that only runs when a server is configured
to *collect* statistics from other installs — not when it merely reports its own. That is a single
deployment, not a normal install. If you have never set `TELEMETRY_COLLECTOR`, 1.9.35 runs correctly
and this release changes nothing for you.
The cause was a reference to the database resolved when the file loaded rather than when the request
arrived, in code that had been moved earlier in the same release.
### Changed — the startup check now covers configuration only one deployment uses
The fault above shipped through a full test suite and every CI job green, because the affected block
is switched on by configuration that no test set. It had never executed anywhere except the one
server that turns it on.
The startup smoke check now boots with that configuration enabled and confirms the routes it adds
actually answer. Code that only one deployment runs is exactly the code an automated check has to
exercise, and it now does.
### Upgrading
No migrations, no configuration changes, and no dependency changes from 1.9.35 — this release only
alters when one value is read. Upgrading from 1.9.34 or earlier, the 1.9.35 note still applies:
`npm ci --omit=dev` is required in both directions, which `scripts/upgrade.sh` already runs.
## 1.9.35
A maintenance release. Two faults where the product was working correctly and still looked broken to
whoever was standing in front of the screen, plus the dependency advisories that could reach a running
server.
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### Fixed — a player could get stuck on an update it was never able to install
A staged update is saved under a filename built from the version the server advertised. If a server
advertised one version while still serving the file for an older one, the player saved the old file
under the new name — and from then on found it, verified its signature, accepted it, and installed
something that changed nothing. The version never moved, so the same update was offered again, and the
player retried the same no-op until it hit its attempt limit.
The signature check passed the whole time, correctly: the file was genuine, it was simply the wrong
one. Worse, fixing the server did not help, because the bad file was reused before anything was
downloaded. Recovery meant deleting the file on the device by hand.
A staged update is now reused only when the version inside the file matches the version being
installed, and a fresh download is checked the same way before it is applied. A server serving the
wrong file now says so — *"server served 1.9.33 but advertised 1.9.34 — the update on the server is
stale"* — and the file is deleted instead of kept. That makes this self-healing: once the server is
corrected, the player recovers on its own.
**Clear update cache** on the device page discards every staged update on a player. The version check
should make it rarely necessary; it exists because a player already holding a bad file predates this
release and cannot benefit from the check, and because the alternative is a cable and a laptop.
### Fixed — directory search showed the system keyboard on top of its own
The directory-search widget draws its own on-screen keyboard, sized and themed to the panel and on by
default. On Android it was never visible. The page puts the cursor in a real text field, which is the
signal for the device to raise its system keyboard — over the bottom of the screen, exactly where the
widget's keyboard is.
So a wall-mounted directory showed the phone keyboard: split across the screen, with microphone, GIF,
emoji and a settings key that opens the keyboard vendor's own interface on a kiosk. On one panel the
only keyboard installed was voice input, so touching the search box opened a microphone. The widget's
own keyboard had been underneath the whole time.
When the widget draws a keyboard, it now tells the device not to raise one. Turn the built-in keyboard
off and the system keyboard behaves as before — with nothing to cover, it is the only way left to type.
### Changed — the dependency advisories that could reach a running server are cleared
Every high-severity advisory affecting a production install is resolved, including eight in the mail
library covering SMTP command injection and header injection. The remaining advisories are in
development-only tooling that is not installed on a server and cannot be reached from one.
The real-time connection to players is deliberately untouched: the fix there was a patch to the message
parser with no change to the format players speak, so nothing about an existing player's connection
changes.
Sending mail was previously covered only by tests that substituted the mail library for a stand-in,
which would have stayed green through any change in the library itself. It is now also tested against
the real one.
### Added — an install that collects statistics can show the total on its landing page
Where install statistics are being collected, the landing page can show how many screens have been
deployed in total. It is an aggregate across every install that chooses to report, so it says nothing
about any single one.
This does nothing on a normal install: the figure is served only where collection is switched on, so a
private server never publishes its own screen count, and the line is hidden entirely rather than
showing a zero.
### Changed — release notes are the written ones
Published release notes now come from this file rather than from a list of commit subjects. The
previous release announced itself as one commit titled "chore(release)" while the entry describing it
sat here unread.
### ⚠️ Upgrading from 1.9.34 reinstalls dependencies
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in
both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
startup where it can reach the npm registry.
Docker deployments need no action; dependencies are installed inside the image.
## 1.9.34
Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what
was there before. Alongside it: the last native image dependency is gone, several players that
could not install updates now can, and an install can optionally report how many screens it runs.
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### Fixed — the old sign-in path could be replayed by any site you had signed into
What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and
then trusted the email address that came back, never asking the only question that matters: *who was
this token issued for?* Any other site a user had signed into — anything holding a token with the
right scope — could replay it against ScreenTinker and receive a session as that user. No password,
no interaction from the victim.
Identity now comes from an **ID token only**, with the signature checked against the provider's
keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider:
Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries
rather than hand-written special cases, which is what removed the two paths that were wrong.
### Added — organizations bring their own single sign-on
Instance-wide providers stay the default and are now unlimited in number. On top of that, an
organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking
OpenID Connect — configured by that organization's own admins in Settings, with no operator
involvement and no restart.
A provider may only assert addresses at domains the organization has **proved it controls**, via a
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to
one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard
zone we do not operate, and it would turn a subdomain takeover into an apex takeover.
An organization's provider never appears publicly. The login page reveals it only after someone
enters an address at a verified domain, so a guessed domain cannot confirm who your customers are.
**Require single sign-on** is available per organization: passwords refused, other providers
refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would
just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the
request, so one compromised org admin cannot quietly reopen password login. Break-glass for a
platform administrator is the correct password and nothing else, and a wrong password returns the
same refusal everyone else gets, so it cannot be used to discover whether an account exists.
⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible
without a reset.
Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other
grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator,
and an organization's own provider is believed once it has verified a domain — the DNS proof stands
in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has
verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone.
Other providers that verify addresses without saying so can opt in with
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
**With no SSO environment variables set, the product behaves exactly as it did before.**
### Added — an existing account can move to single sign-on
Signing in with a provider has always refused to take over an account that already has a password,
and that refusal is right — otherwise anyone who could persuade a provider to assert your address
would inherit your account. But the way out had never been built, so an account created with a
password simply could not use single sign-on.
**Settings → Sign-in method** now offers it, in both directions. An account has exactly **one**
credential: linking **deletes** the password, and the confirmation says so, because a password left
behind is a second way in that you believe you replaced. Unlinking asks for the new password first
and applies both changes together, so the account is never left without a way in.
The account being linked is the one you are **signed in as**, never whichever account matches the
address the provider returns — that is what separates linking from the takeover the login page
refuses. Only providers configured on this server can be linked; an organization's own provider
cannot attach itself to an account.
### Changed — the login page asks who you are before how you sign in
The password box appears once you have entered your address and continued, rather than sitting there
from the start. That is what lets the page check whether your organization uses single sign-on
*before* offering you a credential, so someone whose company requires it is shown that rather than a
password box that was always going to be refused. Correcting your address takes you back a step.
The address is no longer looked up on every keystroke — it answered for half-finished domains,
changed the form under you mid-address, and could exhaust a shared office network's lookup budget
before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so
the page no longer changes shape while you type.
Setup instructions for both operators and organization admins are in
[docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications
— including the one that catches everyone: the Microsoft tenant setting names the directory that
*authenticates the user*, which for personal accounts is not the directory the application is
registered in.
### Changed — image processing no longer needs a native library
Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and
avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and
`better-sqlite3` is the only native module left.
A native module needs a prebuilt binary matching both the platform and the Node version; when there
isn't one the server fails at load with an error that reads like database corruption rather than a
missing image library. That class of failure is gone from this half of the product.
Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif
through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library
in use decodes AV1 but refuses HEVC.
Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a
12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks
an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats
and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with
serving requests.
### Fixed — players that could not install an update
Three separate faults, each able to strand a player on an old version.
**Updates were written to external storage.** Where that location is absent, or exists but cannot be
written to, the download failed the instant it began — before any data arrived — and reported only
that it had failed to download or verify. The same player could be caching content perfectly well
throughout, because content goes to internal storage. Updates now go to the first location that
genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to
it* rather than by asking whether it is writable — the previous check asked, was told yes, and the
write failed anyway.
**Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one
numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved
forward, while the server named the newer build as latest in the same reply. Numbers in version
names are now compared as numbers. The BrightSign host package carried the same comparison and is
fixed with it — there, a wrong answer replaces the script that starts the player.
**A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate
comes from a legacy path that can return nothing. The player now reads the signature itself before
giving up. Verification is unchanged: the certificate is still compared against the installed app,
and anything unsigned, tampered with, or signed by a different key is still rejected.
A failed update now also says which of those things went wrong, instead of one message covering
every possible cause.
⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how
updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by
hand, after which it recovers on its own and stays fixed.
### Fixed — the Android player could leave a band down one edge of the screen
A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar.
It was intermittent because it depended on whether the app was measured before or after the system
UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart.
The stage is now measured from the current window and re-measured when focus changes.
Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem;
pinning the output resolution fixed the corruption, and this fixes the band that remained.
### Added — opt-in install statistics
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
design and should stay that way. A platform administrator is asked, once, whether this install will
share how many screens it runs.
The whole payload is three fields — a random instance ID, the version, and the screen count — and
nothing else: no hostnames, addresses, organization or user names, device names, content or
configuration. Settings shows the **actual payload this server would send**, generated live from its
own data, alongside what it last really sent and when, so the claim can be checked rather than taken
on trust. Turning it on reports immediately, and a blocked outbound connection is named along with
the address to allow, rather than failing silently.
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you
run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who
wants their own numbers and nothing sent to us can set it and leave sharing off.
The random ID exists only so repeat reports from one server count as one server, which makes a
report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in,
any total published from it is a floor, never an estimate of the install base. Full detail in
[docs/telemetry.md](docs/telemetry.md).
### Added — organizations may re-enable same-origin widgets, deliberately
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce
strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a
typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or
admin — a workspace admin is deliberately not enough — and the change is written to the activity
log. Contributed by @ChrisChrome.
The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard
where the admin's session token lives, so honouring the setting there would let anyone who can
author a widget lift the session of whichever admin clicked Preview. The setting exists so
*displays* can embed origin-strict sites; a display holds a device token, an admin's browser does
not.
### Fixed — RSS tickers ran at a speed that depended on how much news there was
Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so
a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read,
and it appeared to jump back to the start. It now holds a constant rate, so more items simply take
proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
### Fixed — user-controlled text is escaped where it reaches the page
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when
switching views, and the main content no longer collapses to a narrow column.
### Added — an operations runbook
[docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both
shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only
obvious once they have bitten you — including three from a Raspberry Pi 5 report, two of which are
not Pi-specific. A piped installer cannot really ask you anything, because the pipe is its input and
every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor
hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD
card by discarding writes — safe for a player, quietly destructive for a server whose database is
written continuously.
### Changed — `better-sqlite3` pinned to 12.9.0
Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database
driver can move independently rather than as one flag day.
The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both
the current and the next Node major; later 12.x releases dropped the older one while still
advertising support for it. A caret range would resolve to one of those and silently turn
installation into a source build. Nothing in the query API changed.
### ⚠️ Upgrading from 1.9.33 reinstalls dependencies
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** —
in both directions.
- **Upgrading**: `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
startup where it can reach the npm registry.
- **Rolling back past this release**: mandatory. Earlier builds load a native image library at
runtime that this release removes, so rolling back the code without reinstalling leaves a server
whose image ingest cannot load its decoder.
Docker deployments need no action either way; dependencies are installed inside the image.
### Known limitations
Deliberately unresolved, and worth knowing:
- Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly
without a reset.
- Turning that requirement back off depends on a platform administrator approving the request; if
nobody does, the organization stays on single sign-on.
- `landing.html` still interpolates plan names into HTML without escaping. Those values come from
the plans table rather than from end users, so it is a loose end rather than an exposure.
- `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves
some waiting. Pre-existing and unchanged by this release.
### Thanks
This release — and a good deal of what came before it — exists because people outside the project
reported problems and sent patches. Credit was recorded inconsistently at the time, so it is
collected here rather than left scattered.
**Code contributed**
- **@ChrisChrome** — the organization-level widget sandbox toggle (#254) and the RSS ticker rate fix,
both in this release. Earlier: the Debian player/server install script (#137) and web player
auto-connect (#6).
- **@BlazzzPlay** — eight merged pull requests across 1.9.4 to 1.9.13: server-side preview sessions
to work around CSP (#151), the Android hidden settings menu (#152), sending device identity on
reconnect before pairing (#164), the dashboard version indicator and update check (#165, #181),
authenticated thumbnail loading (#182), the server URL in the Add Display modal and the Releases
link on the APK download page (#210), and uploads respecting the current folder (#211).
- **@a10kiloham** — boot-time thumbnail healing with ffmpeg diagnostics and packaging (#244), the
screenshot-request verdict toast and the reverse-proxy header pitfall it documented (#243), and a
configurable maximum upload size (#233).
- **@albanobattistella** — the Italian translation, and its updates since (#2, #145, #232).
**Reported**
- **@carloblu74** — the Raspberry Pi 5 report behind #245, which found five defects in the installer
and kiosk launcher that nothing in this repository would have caught, because nothing here had ever
executed those scripts on a Pi. The runbook notes above come from it.
- **@bold-media-group** — by a wide margin the largest source of field reports, across roughly fifty
issues: the OTA rollout and version-advertising problems, event-loop lag under long uptime, video
wall behaviour, Tizen playback regressions, and the content-loading failures that led to resumable
downloads.
- **@Smiley-k**, **@Semetra22**, **@patrickfinardi09**, **@hapishyguy**, **@Nikhil12656**,
**@gittyguy92** and **@Obe-BoldMediaGroup** — bug reports and feature requests across the 1.9.x
line, including SMTP transport, playlist item scheduling, and the Android playlist-order fault
behind #234.
Several of the hardest faults this year were found by someone running the product on hardware the
project does not own. That is worth saying plainly.
## 1.9.33
A patch off 1.9.32. The headline is a boot-time crash that could brick a display permanently — a
player that died on startup, every startup, and could not be recovered by rebooting it. The rest is
the live debug log finally working on the web player, and the playlist-skipping bug that log found
within minutes of being switched on.
### Fixed — a cached playlist could brick a display across reboots
The most serious of these. On startup the player restores its **cached** playlist and renders the
first item immediately. If that item was a video carrying a transition, it read an internal flag
before that flag's declaration had run — which in JavaScript is a *throw*, not an empty value. The
player died during boot.
The loop is what made it fatal rather than annoying: the playlist came from the display's own local
cache, so it never stayed up long enough to receive a corrected one. Every boot re-read the same
cache and died the same way. **Rebooting the player — the one remedy an operator has — did nothing.**
Recovery meant changing the player the server hands out; nothing in the dashboard would have helped.
Found on a BrightSign, but nothing about it was BrightSign-specific: any browser-based display could
have hit it. No customer display was in this state, and the one playlist that mixed video with a
transition happened to start on an image, which was luck rather than protection.
### Fixed — one broken clip could skip several playlist items
A media error scheduled a skip *per error event*, and each new skip orphaned the previous timer
instead of cancelling it, so all of them fired. Four decode errors on one clip meant four advances.
On a single-item playlist that merely replayed the same file, which is why it hid for so long; on a
real playlist it silently dropped the next three items and nothing said why.
One failure now means one skip. A clip that is still playable is no longer discarded on a stray
event, while anything genuinely undecodable is still skipped, so a broken file can never stall a
playlist. Failures also now report the actual media error instead of an anonymous "Video error".
### Added — the live debug log works on browser-based displays
The per-device **Debug logging** checkbox has always sent its command, but only the Android player
ever answered it. The panel opened on every other display and streamed almost nothing.
It now streams what the player has always been recording internally: its own log, uncaught errors
with file and line, failed downloads, and on BrightSign the host's boot report. Switching it on also
**replays what was buffered before you opened it**, timestamped with how long ago each line really
happened — so the failure you came to investigate is already on screen instead of needing to happen
again.
It matters most where there is no alternative: on a signage player there is no console to open and
no cable to attach, and this is the only way to see what the display thinks it is doing.
**Freeze** holds the view still while continuing to buffer underneath, because the moment you freeze
a log is the moment the lines explaining it are still arriving. **Copy** puts the visible capture on
the clipboard, stamped with the display and time, and works on self-hosted dashboards served over
plain HTTP where the browser clipboard API is unavailable. Errors and warnings are now coloured, so
the one line that explains the fault no longer sits in a wall of grey.
### Changed — display controls sit above the status panels
Reboot, screen on/off, launch, force update and shutdown were flush against the status cards, which
read as though they belonged to them.
## 1.9.32
A patch off 1.9.31. The headline is that a BrightSign can finally photograph its own screen; the
rest is a thumbnail library that heals itself, a Raspberry Pi installer that asks the operator
rather than the pipe, IPv6 on the dashboard, and a pairing code you can read from across a room.
### Fixed — a BrightSign can now screenshot itself, video included
That platform has never managed it. Video decodes onto a hardware plane the DOM cannot read, so the
player's in-page canvas composite came back with the content missing, and the panel truthfully but
uselessly reported *"Video is playing on the hardware plane and cannot be captured"* while playing
perfectly.
It now uses **BrightSign's own `@brightsign/screenshot` API**, which composites the video and
graphics layers — exactly the thing a canvas cannot do. The capture is written to RAM rather than
the boot flash: the remote-control view asks for one every second, and a screenshot per second
written to flash wears it out for nothing, since the file is read back and deleted immediately.
Remote control gets it for free — the live view and the screenshot button share one capture path,
so the live view now shows real video instead of a card explaining why it can't.
The long way round is kept as a fallback for firmware without the module, and its own bug is fixed
on the way: the host asked the player's diagnostic web server on a hardcoded port 80, while that
port is configurable and commonly moved (the unit this was found on serves it on 8080 with nothing
on 80 at all). It now reads the port from the registry the server is configured from.
### Fixed — per-item dayparting was silently dead on BrightSign
A BrightSign widget runs with Node integration, which puts `module` into the page's scope. Every
shared module that exported with an `else` therefore took the CommonJS branch and never assigned
its browser global — and every consumer has a silent fallback, so nothing ever complained.
The visible casualty was the transition engine, which is gated on exactly those globals and so
never initialised. The costly one was `schedule-eval`: without it the player falls back to "always
active", so **scheduled content played outside its window** on that platform, with nothing in any
log. Modules now export to both targets.
### Fixed — thumbnails that never appear, and never retry
Thumbnail generation is best-effort by contract, and three gaps made its failures invisible and
permanent: ffmpeg is a system dependency nothing surfaced (and the Docker image did not install
it), a row that missed generation was never retried, and a failed image thumbnail stored a path to
a file that was never written — which the dashboard then requested forever as a broken image.
There is now a `[MEDIA]` startup diagnostic, a once-per-boot backfill that heals old rows, ffmpeg in
the runtime image, and the phantom path is gone. Video probing moved off the synchronous spawn it
had always used: two subprocess calls with a 15-second timeout each, run synchronously, stop the
whole server for their duration — survivable for one human-initiated upload, not for a sweep
walking an entire library unattended.
### Fixed — Raspberry Pi 5 installer (#245)
`curl … | sudo bash` makes stdin the *script*, and bash has consumed it by the time any prompt
runs — so the mode menu answered itself and Player-Only could not be reached through the documented
install at all. Prompts now read the terminal.
Pi 5 on Bookworm defaults to Wayland, where `xset`, `unclutter` and `xrandr` are no-ops that log an
error and do nothing: those Pis had no blanking suppression and no cursor hiding while appearing
configured. The launcher now detects the session and branches. Chromium is told not to ask for a
keyring password no kiosk can answer, and the crash-restore surface that put a white page over the
player on every boot but the first is cleared properly. The login banner also spelled the product
name wrong.
### Added — a display's IPv6 address on the dashboard
The player only ever collected IPv4, so a v6-only panel reported no address at all and the dashboard
showed a dash for a perfectly reachable screen. Both are now reported, in their own fields, because
a dual-stack panel has both and either may be the one you need. Link-local addresses are excluded —
every interface has one and none can be dialled without a zone index.
### Fixed — the pairing code was unreadable on 4K and 8K panels
Every size on the player's setup screens was a hard-coded pixel value. A CSS pixel covers a quarter
of the screen area on a 4K panel that it does on 1080p, and a sixteenth on 8K, so the code that
fills a 1080p screen was a smudge on the wall it was installed on. Sizing is now proportional to the
viewport: identical at 1080p, twice the size at 4K, four times at 8K.
### Fixed — the screenshot button lied when it could not work
The server already answered `offline` or `unsupported`, but no dashboard sender listened, so
clicking Screenshot on an offline display showed "Screenshot requested" and did nothing. The verdict
now surfaces as a toast. Thanks to @a10kiloham for this and for the thumbnail work above.
### Fixed — CI judged the capability baselines against the wrong source
The baselines describe what an un-updated display can do, so they are checked against the shipped
source via a release tag. A shallow checkout has no tags, so the check silently fell back to the
working tree — and a release commit made the newest tag HEAD, flipping every assertion at once.
Both are fixed; the matrix is judged against the previous release.
## 1.9.31
A patch off 1.9.30 carrying the video-wall and playlist-preview work, a QA sweep that drove real
browsers and real panels rather than reading code, and the fix for a loop stall our own maintenance
was inflicting on a customer's fleet every morning.
### Fixed — a wall of portrait panels had to be built backwards (#236)
The wall canvas was secretly framebuffer space, not the wall as you see it. That is invisible while
every panel is the normal way up, and actively misleading the moment one isn't: two portrait-mounted
panels standing side by side had to be **stacked vertically** in the editor, with a pre-rotated copy
of every video, before the output came out right. It worked, but only after trial and error, and it
meant a portrait wall could never reuse existing content.
Each panel now carries a mounting rotation (0/90/180/270), the canvas means the physical wall, and
the player works out the mapping — so side by side is drawn side by side and landscape content plays
across portrait panels unmodified. Applied on the web, Tizen and Android players.
**Existing walls are untouched and need no migration.** Every wall in the field is rotation 0, which
takes the original code path verbatim — an operator who upgrades will not find a wall that was
aligned yesterday has moved. Rebuilding an existing portrait wall the natural way round is an opt-in
change the operator makes when they choose to.
While a display is a member of a wall, its per-panel rotation replaces its own Orientation setting:
the two describe the same physical fact, and honouring both turned the content twice.
### Added — a wall no longer hides its own screens (#235)
Grouping displays into a wall replaced their individual cards, so one dead panel of a four-panel
wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the
wall (re-syncing the live wall) and putting it back. The wall screen now lists its panels with live
online state and a link straight to each device's page, and the wall card on the dashboard shows a
per-member status chip. A screenshot can be requested per panel without disturbing playback.
### Fixed — the checkpointer was stalling the event loop for seconds at a time (#240)
Reported as loop lag that grew with uptime and reset on restart, with a distinctive signature: mean,
p50, p99 and max identical to two decimal places. That signature is not a fixed cost paid on every
cycle. It is what a `perf_hooks` histogram reports when a window recorded **exactly one** delay —
the mean is the raw value and every percentile returns the bucket ceiling above it. Reproducible
against the reported figures to the decimal (`record(1329070000)` gives mean 1329.07, p50/p99/max
1329.59). So the loop took one long turn that swallowed the whole sampling second, episodically.
The long turn was ours, and it is measured rather than argued. Running the real checkpointer worker
against a real WAL with one reader mid-transaction: a single main-thread write blocked for
**4,936 ms**, and the checkpoint that blocked it reported `WAL 8.8MB -> 8.8MB` — it reclaimed
nothing. `wal_checkpoint(TRUNCATE)` is the blocking form and its locks are held across
*connections*, so moving it to a worker thread in 1.9.2-patch3 took the fsync off the loop but not
the lock. It also does not throw when it cannot get those locks: it returns `busy=1` after sitting
on SQLite's five-second busy timeout. Five seconds of stalled loop for no benefit, reported as a
success.
It was reached far too easily. The rule was "escalate if the WAL grew across three consecutive 15s
runs", which **any sustained 45-second write burst** satisfies — a fleet powering on in the morning
does it daily. Escalation now needs the WAL to be in the upper half of its budget
(`WAL_CHECKPOINT_STARVATION_FLOOR_MB`, default 8) **and** to be outside a cooldown
(`WAL_CHECKPOINT_ESCALATE_COOLDOWN_MS`, default 5 min). Both gates are needed: a size floor alone
does nothing for a server whose WAL already sits above it, which was exactly the reported case.
The 16 MB high-water escalation bypasses both gates and is untouched, so *the WAL still cannot grow
unbounded*. A checkpoint that reclaimed nothing now says so in the log instead of reading like a
success.
Also softened the recovery path: when the checkpointer worker is declared unrecoverable, inline
autocheckpoint is re-armed on the main connection — a state that lasts the life of the process, and
therefore looks exactly like "degrades with uptime, a restart fixes it". It used to also run an
unconditional blocking checkpoint on the main thread on the way in; that now happens only above the
high-water mark, and the fallback state is served on `/api/status` rather than being inferable only
from a log line that may have rolled.
### Added — loop-lag telemetry that can be read correctly (#240)
The reported numbers were interpreted, reasonably, as a per-cycle cost, because nothing in them said
how many samples they were made of. `/api/status` now carries `samples` alongside the percentiles
(around 50 in a healthy second, 1 when a single turn swallowed it), `tick_gap_ms` measured on the
wall clock independently of the histogram, and `worst_tick_gap_ms` / `worst_tick_at` — monotone, so
five-minute polling can no longer miss an episode entirely. The `debug` block adds the checkpointer's
worker, fallback and respawn state.
Band semantics are deliberately unchanged: a one-sample window during a real stall is the correct
trigger for the shed valve, and suppressing it would blind the protection at the moment it is needed.
### Fixed — `device_telemetry` grew forever for any display that stopped reporting
The only trim was a per-device row cap applied on that device's own heartbeat, so a decommissioned,
swapped or seasonally-dark panel left its rows behind permanently. There is now a matching age sweep
(`TELEMETRY_RETENTION_DAYS`, default 30), per-device so it rides the existing index rather than
scanning, chunked and yielding like the status-log sweep. The default matches the uptime report's own
default window, so it cannot remove rows that report would have shown.
### Added — a playlist preview you can skip through (#239)
Reviewing item 8 of a playlist cost seven durations of waiting. The preview takes a skip/next
control.
### Added — a video playlist item defaults to the clip's own length (#237)
Rather than the generic default duration, which had to be corrected by hand for every video.
### Fixed — the dashboard preview of a rotated display (#238)
A display rotated 90°/270° was previewed the way its framebuffer is laid out rather than the way
people see it. It now matches what the wall shows.
### Fixed — three controls that did nothing, and a parity matrix that said otherwise
An audit of all four players against their shipped sources found controls a customer can press today
that change nothing. The volume slider worked on Android only: the dashboard sends
`set_volume { level: 0..1 }`, while the web player read `payload.value` and divided by 100 and Tizen
read `payload.value ?? payload.volume` — three complete, working volume implementations that could
not be driven. Correcting only the key would have been worse than leaving it broken, since
`level: 0.5` would have become 0.5%: the scale is now chosen by which key arrived, not by the
magnitude of the number.
Tizen's offline media cache could never have worked on a panel — its adapter used the deprecated
Filesystem API in three ways the IDL rules out, so `MediaCache.create()` returned null on every panel
in the fleet. BrightSign carried calls that compile and are documented to do something else. Every
fix cites the vendor document that proves it, and the linter now fails on each next time.
Four dashboard→device socket handlers had no capability gate, and a re-register could erase a panel's
recorded platform. The parity matrix and the capability baselines are now tested against the players'
**shipped** sources in both directions, so a baseline that over-claims and a player that gains a
handler without its baseline moving both fail the build.
### Fixed — a fresh panel skipped the first item of its playlist
A newly paired panel always learns its playlist before the media arrives, so the 3-second content
re-check is what really begins playback — and it advanced *past* the index already seeded for a
playlist that had not started. The first pass ran 1, 2, 3, 0, and item 1 appeared only after the list
wrapped. Reproduced on the emulator on every fresh pair.
### Fixed — a rotated wall panel screenshotted as a black rectangle
The mounting rotation introduced with #236 is the first real rotation on an ancestor of the video
surface, and the screenshot compositor pasted the frame with an axis-aligned rectangle — so on a
rotated panel it landed outside the capture bitmap and the dashboard received plain black. A panel
that looks dead while it is playing perfectly is the worst thing a diagnostic can say.
### Fixed — the service worker claimed credit for offline widgets it never sees
`sw.js` said its cache-first widget branch was what kept a widget rendering with the network gone. It
is not: the player mounts
widgets in an iframe sandboxed without `allow-same-origin`, making it an opaque-origin client that a
service worker does not control. Measured, not reasoned — five mounts over 25 seconds of real
playback left zero widget entries in the cache.
### Fixed — CI judged the capability baselines against the wrong source
The baselines describe what an un-updated display can do, so they are checked against the shipped
source via the latest tag. The default shallow checkout has no tags, so the lookup found nothing and
the suite silently fell back to the working tree — where a player's payload bug had just been fixed,
making the build demand a baseline change for displays that cannot possibly have the fix yet. Green
locally, red in CI, for a reason visible nowhere in the diff. The test job now fetches tags, and the
bidirectional assertions skip rather than invert when there are none.
## 1.9.30
A patch off 1.9.29 carrying two fixes for faults that are live and silent. Both were found by a QA
pass driving real browsers rather than by reading code, and both fail in the direction that leaves a
screen dark with nothing in any log.
### Fixed — a missing media file answered 200 with the dashboard, cached for a month
`express.static` calls `next()` on a miss and the only thing downstream was the SPA catch-all, so
`GET /uploads/content/<gone>.mp4` returned **200 OK, `Content-Type: text/html`**, 15KB of
`index.html`, under the `public, max-age=2592000, immutable` header the mount had already set on the
way in.
For a player that is the worst possible answer. Every downloader in this product treats 200 as
success, so a panel stores the HTML page **as the video**, caches it for a month, and renders a black
frame. Android's cache validates the byte COUNT against `Content-Length`, not the content type, so a
correctly-sized page passes the integrity check and is promoted as a valid asset.
It is reachable exactly when it hurts: a content replace writes a new randomly-named file and unlinks
the old one, so any snapshot still pointing at the old name asks for a file that is gone. A miss now
terminates in a 404 with no cache header — `immutable` is a promise about a file that exists.
### Fixed — an empty playlist wiped a display's entire offline library
The player asks the service worker to hold its current media and to drop anything else. An empty list
was honoured as "drop everything" — and `assignments: []` is what the server sends for a device
between playlists, for a playlist never published, and from inside the `catch` when a stored snapshot
fails to parse. Reproduced: three cached assets, one empty payload, cache emptied.
That is only survivable while the uplink is up, which is precisely when the offline cache does not
matter. A cache kept too long costs disk the quota reclaims anyway; one dropped at the wrong moment
is a dark screen with no way back. An empty list is no longer a prune instruction.
## 1.9.29
The release candidates 1.9.29-rc1 through rc5 are folded in here; the entries below record what
changed since 1.9.28 in the form it actually ships. Two of these were found only by driving real
hardware and a real browser, and neither could have been caught by a test in this repo.
### Fixed — the web player's offline cache was switched off at the URL everyone uses
A service worker's default scope is its own directory, so `/player/sw.js` could only control
`/player/` **and below** — which does not include `/player` itself, the URL the dashboard shows and
the one panels are configured with. Registration succeeded, logged success, and then controlled
nothing: no shell cache, no content cache, no offline playback, and no error to notice.
### Fixed — screens went black on a bad link instead of playing cached content
The offline playback path was never the problem: the cache could never be **filled**. Every download
began at byte 0 and the partial was discarded on any interruption, so an asset larger than one
uninterrupted transfer was re-fetched forever. Downloads now resume, with `If-Range` and a 416 guard
so a changed or over-long asset can never be spliced.
### Added — every player caches media for offline playback
Tizen caches the media itself now, not just the playlist; the web player (and BrightSign) accumulate
in resumable chunks driven by the playlist rather than by playback. Content carries a revision, so
replacing an asset reaches displays that already hold the old bytes — previously it could not, ever.
### Added — players declare what they can actually do
Each player reports its real capabilities at registration and the dashboard stops offering controls
that cannot work. A display that declares nothing keeps its per-platform baseline, so nothing in the
field loses controls on upgrade.
### Fixed — the BrightSign host scripts were written against Roku's API reference
BrightScript is Roku's language and the two references read alike, so calls to objects that do not
exist looked exactly like calls to ones that do. A string literal that stopped the script compiling,
an existence check that could never return true, and a self-update path that could never mark a
package applied — all corrected, and guarded by a checker, since nothing in CI can run BrightScript.
## 1.9.29-rc5
### Fixed — the BrightSign host scripts were written against Roku's API reference
BrightScript is Roku's language, the two references read almost identically, and nothing in CI can
run either — so a call to an object that does not exist looked exactly like a call to one that does.
Found by auditing against BrightSign's published reference after a consultant's deployment failed,
and verified on an XT245.
- **A string literal stopped the whole script loading.** `"{""width"":"` is not an escaped quote;
BrightScript has no escape sequences, so it is three adjacent literals with no operator between
them. The compiler rejects the entire file — `ScriptLoadError: Syntax Error (compile error &h02)`
— which is not a broken feature but **no player at all**, on a display showing nothing.
- **`MatchFiles` was called with a path as both arguments.** It takes a DIRECTORY plus a pattern and
returns nothing when the pattern contains a separator, so the existence check could never return
true for any file on any player. That is the reported failure: `no autorun.zip on any volume`
printed while `dir SD:` listed it. It also silently disabled the entire self-update path.
- **Roku objects that do not exist on BrightSign**, each quietly disabling a feature: `roFileSystem`
(~20 sites — an update could never be marked applied), `roMessageDigest` (verification returned
false unconditionally and burned the retry counter), `PostFromStringWithRetry` (a snapshot request
raised "member function not found" from inside the event loop and took the player down).
- **`Unpack()` deletes everything already in its target directory.** Unpacking an update to the
volume root would have erased the player's provisioning and its whole content pool as a side
effect of a routine upgrade. It now stages to a directory of its own and never overwrites
`screentinker.json`.
- Rotation moves to `SetScreenModes()` (`SetMode()` takes one argument) and fires only on a real
change, because that call reboots the player.
`server/test/brightscript-api-surface.test.js` guards all of it — a deny-list of Roku APIs plus the
argument shapes and literal forms that compile and then do nothing.
### Fixed — a player that could not cache was telling the fleet it could
A real BrightSign exposes `navigator.serviceWorker`, passes an `'serviceWorker' in navigator` check,
and then never even fetches the worker: its runtime refuses to register one. It advertised
`offline.cache` while unable to cache a byte. The capability is now claimed only when a worker is
genuinely in control, and a refused registration reports itself to the server instead of a
`console.warn` on a display nobody has a console for.
### Fixed — storage paths assumed a card slot that may not exist
`StorageRoot()` knew only internal flash and SD. Fitting real storage to a flash-booting player and
moving the deployment onto it resolved every derived path — the offline page, the widget's local
storage, the update paths — to a slot with nothing in it. It now probes in the order the OS itself
searches for an autorun script. The widget's `storage_path` is likewise an absolute path on the boot
volume rather than a bare `/cache`, which carried no drive specifier and so had nowhere to persist.
## 1.9.29-rc4
### Fixed — the web player's offline cache was switched off at the URL everyone uses

View file

@ -6,7 +6,9 @@
# No TLS in the image: it listens on plain HTTP :3001. Front it with a
# TLS-terminating reverse proxy / Cloudflare in production.
# --- builder: install production deps (native: better-sqlite3, sharp) ---
# --- builder: install production deps (better-sqlite3 is the only native one left; image
# decoding is pure JS + WASM since sharp was dropped, and sharp is now a devDependency that
# --omit=dev leaves out entirely) ---
FROM node:20-slim AS builder
WORKDIR /app/server
# build toolchain in case a native prebuild is missing for the target arch
@ -18,6 +20,11 @@ RUN npm ci --omit=dev
# --- runtime ---
FROM node:20-slim
# ffmpeg (ships ffprobe) powers video thumbnails + duration extraction at upload.
# Without it videos still upload and play, but arrive with no thumbnail or duration.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
# Relocate all state onto the volume (config.js reads DATA_DIR; unset would use
# the in-repo paths, which we do not want in a container).

323
README.md
View file

@ -114,6 +114,11 @@ self-update — new versions are installed the same way the first one was.
- Node.js **20.6+** (the npm scripts use the built-in `--env-file-if-exists` flag, added in 20.6)
- Linux, macOS, or Windows
- SQLite (bundled via `better-sqlite3`; no separate install needed — `npm install` handles the native bindings)
- **ffmpeg** (optional but recommended) — powers video thumbnails and duration extraction
(`sudo apt-get install ffmpeg` / `brew install ffmpeg`). Without it, videos upload and
play fine but show no thumbnail in the content library. The Docker image includes it.
The server logs a `[MEDIA]` line at startup telling you whether it was found, and
backfills missing thumbnails automatically once ffmpeg appears after a restart.
### Quick Start
@ -313,31 +318,243 @@ advertising it — put the account on the hidden plan and it simply gets those l
above deliberately lists hidden plans too (marked as such), because the previous behaviour was that
a hidden plan was invisible to the operator as well as the customer.
#### Google OAuth
#### Single sign-on (OpenID Connect)
Let users sign in with Google.
> **Setting it up?** [**docs/sso-setup.md**](docs/sso-setup.md) is the step-by-step guide — Google and
> Microsoft console walkthroughs, per-organization SSO, account linking, and a table of every error
> code with its actual cause. The rest of this section is the reference.
1. Create a project in [Google Cloud Console](https://console.cloud.google.com)
2. Enable the Google Identity API
3. Create OAuth 2.0 credentials (web application)
4. Add `https://yourdomain.com` as an authorized origin
Any OIDC provider works — Google, Microsoft/Entra, Okta, Auth0, Keycloak, Authentik, Zitadel — through
one flow: **Authorization Code with PKCE, run server-side**. The browser never talks to the provider
directly, so there is no SDK to load and no third-party script origin to allow in the CSP.
Every login is verified as an **ID token**: signature against the provider's published JWKS,
`iss` exactly as discovered, `aud` (and `azp`) matching your client, `exp`, and a `nonce` this server
generated for that specific login. An access token is never accepted as proof of identity.
Set the redirect URI at your provider to:
```
https://yourdomain.com/api/auth/oidc/<slug>/callback
```
Set `APP_URL` so that origin is pinned — the redirect URI must match your provider's registration
exactly, and deriving it from the request `Host` would both break behind a second hostname and take
its value from the caller.
**Google** and **Microsoft** need only the variables this README has always documented; their issuer
is filled in for you and their slugs are `google` and `microsoft`:
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Your Google OAuth client ID |
| `GOOGLE_CLIENT_ID` | OAuth 2.0 client ID from [Google Cloud Console](https://console.cloud.google.com) |
| `GOOGLE_CLIENT_SECRET` | Optional — PKCE means a public client works |
| `MICROSOFT_CLIENT_ID` | Application (client) ID from the [Azure portal](https://portal.azure.com) |
| `MICROSOFT_TENANT_ID` | **Your tenant GUID — required.** `common`/`organizations` are refused |
| `MICROSOFT_CLIENT_SECRET` | Required in practice — register the redirect URI under the **Web** platform, which Entra treats as a confidential client. A **SPA** registration is rejected at the token endpoint, because this exchange runs server-side and sends no browser `Origin` |
#### Microsoft OAuth
Register the redirect URI under **Web**, add the **`email`** optional claim under *Token configuration →
ID*, and note that **Entra ID v2 does not send `email_verified`** — ScreenTinker treats a
tenant-pinned Microsoft entry as vouching for the address rather than demanding a claim Microsoft
never emits. An explicit `email_verified: false` is still refused, and an organization's own provider
can never make that assumption.
Let users sign in with Microsoft/Azure AD.
⚠️ **Multi-tenant Microsoft (`common`) is deliberately refused, and Microsoft sign-in stays disabled
until you set a tenant GUID.** Two reasons that point the same way. It cannot work: Microsoft's
multi-tenant metadata advertises the literal template `https://login.microsoftonline.com/{tenantid}/v2.0`,
so the issuer never matches and every login fails anyway. And the obvious fix is dangerous — accepting
that template means accepting tokens from *every* Azure tenant, which is
[nOAuth](https://www.descope.com/blog/post/noauth): any tenant admin can set an arbitrary, unverified
`email` on one of their own users and be issued a session as that address. Safe multi-tenant support
needs per-tenant pinning (validate `tid` against an allowlist, key accounts on `oid`+`tid` rather than
email) and is not implemented.
1. Register an app in [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps)
2. Add a web redirect URI: `https://yourdomain.com`
3. Note the Application (client) ID
**Any other provider** is added by slug:
| Variable | Description |
|----------|-------------|
| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID |
| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) |
```bash
OIDC_PROVIDERS=okta,authentik
OIDC_OKTA_ISSUER=https://example.okta.com
OIDC_OKTA_CLIENT_ID=0oa...
OIDC_OKTA_NAME=Okta # optional button label
OIDC_OKTA_CLIENT_SECRET=... # optional — PKCE means a public client works
OIDC_OKTA_SCOPES=openid email profile # optional
OIDC_OKTA_ASSUME_EMAIL_VERIFIED=true # only if the IdP verifies addresses but omits the claim
```
The issuer is the base URL whose `/.well-known/openid-configuration` describes the provider; endpoints
and keys are discovered from it and cached.
**Account rules.** A provider must assert a verified email, because the whole account model keys on
it. An SSO login never takes over an existing account that has a password — the owner signs in
locally and links from Settings. If the provider's stable subject (`sub`) changes for an address, the
login is refused rather than handing an account to a recycled mailbox.
An account established by one provider is **not** adopted by another. A per-organization provider may
only claim an account that its own organization established, or a `local` account that has never set
a password (an invited user signing in for the first time); anything else is refused with
`account_exists_other_provider`. The earlier rule — "any account without a password may be re-pointed
at whichever provider spoke last" — was safe only while the operator chose every provider, and became
an account-takeover primitive the moment customers could add their own.
⚠️ **TOTP is not prompted on an SSO login.** Second-factor is the identity provider's job in this
flow, matching the long-standing behaviour of the SSO and API-token paths.
#### Per-organization SSO (customer-configured)
The providers above are **instance-wide** — they belong to whoever runs the server and appear as
buttons on the login page for everyone.
An organization can also bring **its own** identity provider, configured by an org owner or admin in
**Settings → Single sign-on**. No environment variable or restart is involved.
A per-org provider is **never listed publicly**. It appears only when someone types an email address
at one of that organization's **verified** domains, at which point the login page offers a generic
"Continue with single sign-on" button. The domain lookup answers only whether that domain uses SSO
and whether it is required — never a provider name or slug — so a guessed domain cannot confirm who
a customer is, and the mapping back to a provider happens server-side on submit. Both endpoints are
rate limited.
**Instance-wide is the default; an organization overrides only its own verified domains.** Type an
address whose domain no organization has verified and you get the local password form plus every
instance provider you configured. Type one that an organization has verified and its own button is
added — and if that organization requires SSO, it becomes the only option.
Each provider gets a randomly generated redirect URI, shown in Settings, which the admin registers
with their identity provider:
```
https://yourdomain.com/api/auth/oidc/<generated-slug>/callback
```
The slug is generated rather than chosen so two customers cannot collide on — or guess — each
other's. A domain may be claimed by only one organization; a second claim is refused.
A customer bringing **Microsoft/Entra** registers a single-tenant application in their own directory
and uses `https://login.microsoftonline.com/<their-tenant-guid>/v2.0` as the issuer. Because Entra
does not send `email_verified`, an organization's provider is trusted to assert addresses **once it
has verified a domain** — the DNS proof is what stands in for the claim, and the provider is confined
to those domains regardless. A provider that has verified nothing assumes nothing, and an explicit
`email_verified: false` is refused whoever sends it.
⚠️ **A provider may only authenticate emails inside the domains it has VERIFIED.** An organization
supplies its own issuer and client ID, so it controls that identity provider completely and could
otherwise assert any address at all — including another company's, or an administrator's. Confining
assertions to verified domains is what makes customer-configurable SSO safe to offer.
⚠️ **Public email providers cannot be claimed.** `gmail.com`, `outlook.com`, `yahoo.com`, `icloud.com`
and the rest of the consumer mailboxes are refused (`server/lib/public-email-domains.js`). Claiming
one would offer every Gmail user a "sign in with your organization" button pointing at one tenant's
infrastructure — phishing launched from this product's own login page — and would let one account
deny a public domain to everyone else.
##### Proving a domain
A claimed domain **routes nobody and authenticates nobody until DNS proves the organization controls
it.** Typing a domain into a form reserves the name and nothing more.
Publish this record, then press **Verify**:
```
_screentinker-verify.example.com. IN TXT "st-verify=<token>"
```
The token is unique per domain, so publishing one proof cannot be replayed to claim a second. A
dedicated `_`-prefixed name is used rather than the apex, where a careless edit would sit alongside
SPF and DMARC and break mail — and where a wildcard `*.example.com` could not be confused for a
proof, since a wildcard answers with its own value and never with the token.
TXT is the only accepted form. A CNAME alternative would have to point at a wildcard zone this
project operates, answering for every token ever issued; documenting one without running it would
describe a check that can never pass.
⚠️ **The proof name itself must not be a CNAME.** A TXT lookup follows CNAMEs, and a wildcard
`*.example.com` covers `_screentinker-verify.example.com` too — so a wildcard CNAME would let
whoever controls its target prove the domain, turning an ordinary subdomain takeover into control of
every `@example.com` login. A delegated proof name is refused, which is stricter than ACME's dns-01.
**An unverified claim lapses after 8 hours, and lapsing RELEASES it.** Pressing Verify on an
expired claim does not reissue it in place — that renewed the clock, so one request per window held
a domain forever. The claim is released, the domain becomes free for anyone else, and re-adding it
is a new claim: new token, and the operator is notified again. A verified domain never expires;
re-proving on a timer would log a customer out over a DNS edit made months afterwards. Squatting is
not made impossible — it is made loud.
**Deleting a provider releases its domains and returns its accounts to local sign-in**, so the
organization can re-claim its own domain and its people can recover by password reset. Both used to
be stranded: a verified domain row outlived its provider and blocked that domain for everyone
permanently, and its users could neither sign in nor reset.
Platform admins are emailed whenever a domain is claimed. Verification is what makes an unowned
claim worthless; the notification is what makes an attempt visible. Nothing is ever sent to the
claimed domain itself — that would let any tenant make this product email third parties.
⚠️ **Instance-wide providers are exempt from all of the above.** `GOOGLE_CLIENT_ID`, `OIDC_*` and
friends are the operator's own configuration, are not domain-restricted, and require no verification.
Domain proof exists because per-organization providers are supplied by CUSTOMERS.
Signing in through an organization's provider makes the user a member of that organization
(`org_member`). Existing members keep whatever role they already have — logging in never promotes or
demotes anyone. Client secrets are optional (PKCE), and are stored AES-256-GCM encrypted and never
returned by the API.
##### Requiring single sign-on
An organization can turn off password sign-in for its verified domains, so its identity provider is
the only way in — which is the point of buying SSO: the IdP holds the MFA, the conditional access
and the instant removal of access, and a password box beside it is a way around all three.
Settings → Single sign-on → **Require single sign-on**. It needs at least one verified domain, so an
organization cannot leave its own people with no way to sign in, and cannot switch off passwords for
a domain it merely typed.
When it is on:
- the login page **hides** the password field for those domains rather than letting someone type a
password that is going to be refused and then send them to reset it;
- `POST /api/auth/login` refuses with `403 sso_required` — distinguishable from a wrong password,
because the page must not tell a user to fix a credential that is not the problem;
- **every other identity provider is refused too**, including the instance's own Google or
Microsoft. Those belong to the operator and are not domain-restricted, so leaving them available
would be a side door straight past the customer's MFA — blocking passwords while leaving
"Continue with Google" is not requiring single sign-on, it is renaming the bypass.
**Turning it off is a request, not a switch.** That direction re-opens password sign-in, so it is
the direction an attacker who has taken an org admin would take, and it is also what a customer will
demand at their worst moment — identity provider down, nobody can work — which is exactly when a
self-service toggle gets flipped without thinking. The org admin files a request; a **platform admin
approves it**, and nothing changes until they do.
The approval email deliberately carries **no action link**. A token that acts on its own would turn
every forwarded, archived or auto-previewed copy of that message into a way to switch off a
customer's single sign-on. The decision is made signed in, under Admin.
⚠️ **`platform_admin` is exempt from enforcement, and that exemption is load-bearing.** The operator
is who approves removal. If the operator's own address sat at an SSO-only domain and that identity
provider broke, nobody could sign in to approve anything and the instance would be bricked with no
way out. It is the break-glass — it applies to the people running the server, never to a customer's
own admins.
⚠️ **This makes the approval queue an availability dependency.** An organization whose IdP breaks is
locked out until an operator acts. That is the intended trade — deliberate friction on the dangerous
direction — but it should be a decision, not a surprise.
#### Dependency preflight on boot
Before anything else is loaded, the server checks that the packages this build declares are actually
installed and that the native database module loads under the running Node. If either is wrong it
repairs it (`npm install --omit=dev`, or `npm rebuild better-sqlite3`) and continues; if it cannot,
it exits saying what to run rather than dying on a `MODULE_NOT_FOUND` naming a file.
`scripts/upgrade.sh` already installs dependencies, so this is not for the normal path. It is for
the ways a box ends up with the wrong `node_modules`:
- **rolling back** to an older tag restores that tag's `package.json` but not its packages — and you
are rolling back because something is already wrong;
- **upgrading Node** leaves `better-sqlite3` compiled against the previous ABI, which fails in a way
that reads like database corruption and is not.
Set `ST_SKIP_DEP_PREFLIGHT=1` on an air-gapped host, or anywhere you manage `node_modules` yourself
and do not want a boot reaching for the registry.
#### Email (Microsoft Graph or SMTP)
@ -413,6 +630,10 @@ Use this in local dev when running against a fresh production database clone to
- **Sequential send pattern** through the offline-alert backlog — avoids Graph's per-app concurrent-send throttling (HTTP 429 `ApplicationThrottled`)
- **Per-user opt-out** via the `email_alerts` toggle in Settings → Account; respects user preference before any Graph call
> **Running one day to day?** [**docs/operations.md**](docs/operations.md) is the runbook —
> deploy and rollback for both shapes, how to verify a deploy actually took, the served-APK rules,
> and the traps that have cost real time.
### Production Deployment
For production, put the app behind a reverse proxy (nginx, Caddy, etc.) with SSL:
@ -425,7 +646,8 @@ sudo useradd -r -s /bin/false screentinker
sudo cp -r . /opt/screentinker
sudo chown -R screentinker:screentinker /opt/screentinker
# Install dependencies
# Install dependencies (ffmpeg is for video thumbnails + durations — see Requirements)
sudo apt-get install -y ffmpeg
cd /opt/screentinker/server && npm install --production
# Create a systemd service
@ -499,6 +721,28 @@ server {
}
```
#### Don't add security headers at the proxy
The app already sets `X-Frame-Options`, `Strict-Transport-Security`, `Content-Security-Policy`,
`X-Content-Type-Options`, etc. via [helmet](https://helmetjs.github.io/), and manages them
**per route**: widget/kiosk renders and the device preview deliberately remove or relax
`X-Frame-Options` so they can be framed, while the dashboard keeps the strict policy.
A proxy-level header block (nginx `add_header X-Frame-Options DENY;`, a Caddy
`header { ... }` snippet, or a "security headers" preset) *adds a second copy* of these
headers on top of the app's. Browsers treat conflicting duplicate `X-Frame-Options`
values as `deny`, which breaks the dashboard's device Preview (a same-origin iframe of
`/player`) and widget previews with console errors like:
```
Refused to display 'https://…' in a frame because it set multiple
'X-Frame-Options' headers with conflicting values ('DENY, SAMEORIGIN').
Falling back to 'deny'.
```
Let the proxy handle TLS, compression, and body-size limits only, and leave security
headers to the app.
### Updating
To update a running instance to the latest version:
@ -634,13 +878,56 @@ keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -
2. Go to **Displays** and click **Add Display**
3. Install the ScreenTinker app on your device:
- **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above)
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash`
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | sudo bash` (see [Raspberry Pi notes](#raspberry-pi-notes))
- **Debian 13 (headless)**: `curl -sSL https://your-instance/scripts/debian-13-setup.sh | sudo bash`
- **Windows**: Run the setup script from `scripts/windows-setup.bat`
- **Samsung Tizen TV / signage**: point the TV's URL Launcher (or browser) at `https://your-instance/player` - no signing needed. For an installed native app, see [tizen/README.md](tizen/README.md)
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
4. Enter the pairing code shown on the device
### Raspberry Pi notes
**Run it with `sudo`.** The script installs packages and writes systemd units, so it refuses to
run otherwise. Piping is fine — prompts are read from your terminal, not from the pipe:
```bash
curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | sudo bash
```
To pick Player-Only without being asked:
```bash
curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://your-server
```
**Pi 5 / Bookworm runs Wayland by default.** The kiosk launcher detects the session and does the
right thing on either: `xset`/`unclutter` are X11-only and are skipped on Wayland (where they are
no-ops that log an error and silently do nothing), Chromium is given `--ozone-platform=wayland`,
and `--password-store=basic` stops it asking for a keyring password no kiosk has anyone to answer.
Blanking and cursor-hiding belong to the compositor on Wayland. The launcher calls `wlopm` when it
is present; if your image does not ship it, set the equivalent in your compositor's config
(`~/.config/wayfire.ini` `[idle]` for wayfire, or the labwc equivalent).
**A white page on every boot but the first** was Chromium restoring a session it believed crashed —
a kiosk is killed by shutdown and never exits cleanly, so it came back with a restore surface on
top of the player. The launcher now clears the stored session as well as the clean-exit flag.
#### Read-only root (Overlay FS) on a Pi that loses power
Worth enabling for **Player-Only** installs, where the Pi holds no state you cannot recreate: the
overlay absorbs writes into RAM, so a power cut cannot corrupt the card and the flash does not wear
out. Re-run the setup script (or `raspi-config` → Performance → Overlay File System) *after* the
install, and remember that pairing is stored on the device — re-pair once with the overlay
disabled, then enable it, or the pairing is lost at every reboot.
**Do not enable it on an All-in-One install without moving the data first.** That Pi *is* the
server: the SQLite database, uploaded media and the JWT secret live under `/opt/screentinker`, and
an overlay discards every write at reboot — so content you upload and displays you pair vanish on
the next power cycle. If you want both, put `DATA_DIR` on a writable partition or an external
drive that is excluded from the overlay, and confirm the database file is genuinely outside it
before trusting the setup.
On the Android player, the setup screen lists the permissions it wants and lets you revisit any of
them later — each row stays visible once granted and turns into **Manage**, so you can check or
revoke what you gave it rather than having the option disappear.

View file

@ -1 +1 @@
1.9.29-rc4
1.9.36

View file

@ -13,8 +13,8 @@ android {
targetSdk = 34
// Env-overridable so device-owner reinstalls (which require an ever-increasing
// versionCode — downgrades are blocked) don't churn this file each build.
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "101").toInt()
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.29-rc4"
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "123").toInt()
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.36"
}
signingConfigs {
@ -87,8 +87,24 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
// Socket.IO client
implementation("io.socket:socket.io-client:2.1.0")
// Socket.IO client.
//
// org.json is excluded deliberately. socket.io-client pulls org.json:json:20090211
// transitively, and that artifact was being packaged into the APK in full — 19 classes,
// including CDL, XML, JSONML and its own Test class. It carries the JSON License, whose
// "shall be used for Good, not Evil" clause is not OSI-approved, is treated as non-free by
// Debian and Fedora, and is Category X at Apache. Shipping it in a commercially distributed
// binary is an avoidable licensing problem: it is not copyleft, but it is not a licence we
// want to have to explain.
//
// Nothing is lost. Android provides org.json in the platform (since API 1, and minSdk is 24),
// and the only classes either side actually touches are JSONObject, JSONArray and JSONTokener.
// The full method surface used — by socket.io/engine.io and by our own Kotlin — is
// get/getString/getLong/getJSONArray/getJSONObject/has/keys/length/isNull/put/NULL,
// the opt* family, and JSONTokener.nextValue. Every one is platform API.
implementation("io.socket:socket.io-client:2.1.0") {
exclude(group = "org.json", module = "json")
}
// WorkManager for background downloads
implementation("androidx.work:work-runtime-ktx:2.9.0")

View file

@ -149,6 +149,27 @@ class MainActivity : AppCompatActivity() {
// Fullscreen immersive
@Suppress("DEPRECATION")
/*
* Ask for a full-bleed window through BOTH APIs.
*
* systemUiVisibility has been deprecated since API 30 and some OEM builds honour it only
* partially: `dumpsys window` on one RK356x box reported `init=1920x1080 app=1920x1024`,
* i.e. the firmware kept reserving 56px for a navigation bar that was set to hide, so the
* app was never given those pixels to paint. WindowCompat is the supported route on those
* builds. Both are set because neither is reliable alone across signage hardware: the
* legacy flags still carry older devices, the compat API carries newer and OEM ones.
*
* Verify per device rather than assume `adb shell dumpsys window displays` should show
* app= equal to init=. If it still does not, the reservation is a firmware behaviour no
* app-side call can override and it has to be turned off on the device.
*/
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
androidx.core.view.WindowInsetsControllerCompat(window, window.decorView).apply {
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
systemBarsBehavior =
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
@ -370,12 +391,57 @@ class MainActivity : AppCompatActivity() {
// (playerView/imageView/youtubeWebView) and multi-zone (ZoneManager renders into
// the same rootView). Values mirror the dashboard: landscape / portrait /
// landscape-flipped / portrait-flipped.
private fun applyOrientation(orientation: String) {
if (orientation == currentOrientation) return
currentOrientation = orientation
/**
* The size of the WINDOW we are allowed to paint, measured NOW.
*
* Deliberately the window and not the display. On a box whose firmware keeps reserving space
* for a system bar, `dumpsys window` reports e.g. `init=1920x1080 app=1920x1024`: the panel is
* 1080 tall but the window is 56px shorter, and those 56px are simply not ours to draw in.
* Sizing the stage to the DISPLAY there would not fill the gap it would push the bottom of
* every asset outside the window and silently crop it, which is worse than a border.
*
* The original defect was not which size was read but WHEN: `resources.displayMetrics` was read
* once, while a bar was still on screen, and written into rootView's layoutParams for good.
* Immersive mode is a request bars hide and the window grows a few frames later so a
* playlist arriving first froze the stage at bar-sized dimensions, leaving dead space exactly
* the size of a bar that had since vanished. It looked random because it was a race, and
* rendering the cached playlist immediately at boot made losing it the common case.
*
* So: read it late, read it again whenever the window changes, and never cache it across a
* window resize. See reapplyOrientation().
*/
private fun windowSize(): Pair<Float, Float> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val b = windowManager.currentWindowMetrics.bounds
return b.width().toFloat() to b.height().toFloat()
}
val m = resources.displayMetrics
val w = m.widthPixels.toFloat()
val h = m.heightPixels.toFloat()
return m.widthPixels.toFloat() to m.heightPixels.toFloat()
}
/** Stage size last applied, so a stage sized during a transient window state can heal. */
private var appliedStageW = 0f
private var appliedStageH = 0f
/**
* Re-run the current orientation against the panel size as it is NOW.
*
* Called when the window settles (focus regained, bars finally hidden). Without this, a stage
* measured too early is permanent: applyOrientation() returns immediately when the orientation
* string has not changed, and it never changes on a display that has always been landscape.
*/
private fun reapplyOrientation() {
applyOrientation(currentOrientation ?: "landscape")
}
private fun applyOrientation(orientation: String) {
val (w, h) = windowSize()
// The guard compares the measured SIZE as well as the orientation. Comparing the string
// alone is what made a bad measurement unrecoverable.
if (orientation == currentOrientation && w == appliedStageW && h == appliedStageH) return
currentOrientation = orientation
appliedStageW = w
appliedStageH = h
val (rot, swap) = when (orientation) {
"portrait" -> 90f to true
"portrait-flipped" -> 270f to true
@ -447,8 +513,17 @@ class MainActivity : AppCompatActivity() {
// Video-wall slice transform. The content view represents the whole wall (player_rect);
// size + offset rootView so this screen's screen_rect fills the device viewport, content
// stretched to fill (object-fit:fill parity, set on the views via MediaPlayerManager).
// Mirrors the web player's vw/vh stage math. Per-tile rotation is intentionally not
// applied (web/Tizen parity). cfg == null restores full screen.
// Mirrors the web player's vw/vh stage math. cfg == null restores full screen.
//
// #236: per-panel mounting rotation is now applied. Ported by hand from
// server/lib/wall-geometry.js, which is the canonical rule and the only place it is tested —
// Kotlin cannot load the shared script the web player pulls, so any change there has to be
// mirrored here or a wall of mixed players grows a seam. `rotation` is degrees CLOCKWISE the
// content is turned inside the framebuffer (the same convention as the orientation setting),
// and Android's View.rotation is clockwise-positive too, so it maps straight across.
//
// Transform order matters: Android rotates about the pivot (view centre) and THEN applies
// translation, so the translation is computed to land the view's CENTRE, not its top-left.
private fun applyWallTransform(cfg: WallController.WallConfig?) {
val lp = rootView.layoutParams
if (cfg == null) {
@ -476,19 +551,49 @@ class MainActivity : AppCompatActivity() {
}
val dw = resources.displayMetrics.widthPixels.toFloat()
val dh = resources.displayMetrics.heightPixels.toFloat()
lp.width = ((p.w / s.w) * dw).toInt()
lp.height = ((p.h / s.h) * dh).toInt()
rootView.layoutParams = lp
rootView.translationX = ((p.x - s.x) / s.w) * dw // negative for right/lower tiles
rootView.translationY = ((p.y - s.y) / s.h) * dh
rootView.rotation = 0f // per-tile rotation: TODO (parity = none)
val rot = when (cfg.rotation) { 90 -> 90; 180 -> 180; 270 -> 270; else -> 0 }
if (rot == 0) {
// Left byte-identical to the pre-#236 expression on purpose: every wall in the field is
// rotation 0, and an operator who updates must not find a wall that was aligned
// yesterday has shifted by a rounding error.
lp.width = ((p.w / s.w) * dw).toInt()
lp.height = ((p.h / s.h) * dh).toInt()
rootView.layoutParams = lp
rootView.translationX = ((p.x - s.x) / s.w) * dw // negative for right/lower tiles
rootView.translationY = ((p.y - s.y) / s.h) * dh
rootView.rotation = 0f
} else {
// A quarter turn measures the wall's horizontal against the framebuffer's VERTICAL —
// on a panel hung sideways, moving right along the wall moves down the display.
val quarter = (rot == 90 || rot == 270)
val boxW = (p.w / s.w) * (if (quarter) dh else dw)
val boxH = (p.h / s.h) * (if (quarter) dw else dh)
// Where the player rect's centre sits within this panel's rect, 0..1 in wall space.
val nx = (p.x + p.w / 2f - s.x) / s.w
val ny = (p.y + p.h / 2f - s.y) / s.h
// ...and where that lands in the framebuffer once the panel's turn is undone.
val cx: Float
val cy: Float
when (rot) {
90 -> { cx = 1f - ny; cy = nx }
180 -> { cx = 1f - nx; cy = 1f - ny }
else -> { cx = ny; cy = 1f - nx } // 270
}
lp.width = boxW.toInt()
lp.height = boxH.toInt()
rootView.layoutParams = lp
rootView.rotation = rot.toFloat()
rootView.translationX = cx * dw - boxW / 2f
rootView.translationY = cy * dh - boxH / 2f
}
rootView.scaleX = 1f
rootView.scaleY = 1f
rootView.requestLayout()
mirrorTransformToPip()
// Orientation no longer reflects reality; ensure it re-applies after wall exit.
currentOrientation = null
Log.i("MainActivity", "Wall transform: size=${lp.width}x${lp.height} tx=${rootView.translationX} ty=${rootView.translationY}")
Log.i("MainActivity", "Wall transform: size=${lp.width}x${lp.height} tx=${rootView.translationX} ty=${rootView.translationY} rot=$rot")
}
private fun setupServiceCallbacks() {
@ -836,6 +941,14 @@ class MainActivity : AppCompatActivity() {
if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true)
}
// #161 device-owner tooling: push + silently install an arbitrary APK from a URL.
// Escape hatch for a panel holding a stale/bad staged APK: drop every cached file
// so the next check downloads afresh. Only ever deletes caches.
"clear_update_cache" -> {
if (::updateChecker.isInitialized) {
val n = updateChecker.clearUpdateCache()
Log.i("MainActivity", "clear_update_cache removed $n file(s)")
}
}
"install_apk" -> {
val url = payload?.optString("url", "") ?: ""
if (url.isNotBlank() && ::updateChecker.isInitialized) {
@ -1472,6 +1585,16 @@ class MainActivity : AppCompatActivity() {
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
/*
* Re-measure the stage once the window has settled.
*
* Hiding the bars is asynchronous, so the window is often still bar-sized when the
* first playlist arrives and sizes the stage. Without this the mistake is permanent:
* applyOrientation() used to return immediately whenever the orientation string was
* unchanged, and it never changes on a display that has always been landscape. Posting
* it runs after this layout pass, when the window is whatever it is finally going to be.
*/
rootView.post { reapplyOrientation() }
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or

View file

@ -544,7 +544,10 @@ class PlaylistController(
retryRunnable = Runnable {
if (isRunning && items.isNotEmpty()) {
if (firstActiveIndex() < 0) { showNothingScheduled(); return@Runnable }
val idx = PlaylistSelection.nextPlayableIndex(items.size, currentIndex) { playableNow(it) }
// Include currentIndex when nothing is on screen: there it is the intended START,
// not a position that has had its turn. Skipping it dropped item 1 on every cold
// start, because a fresh panel always gets the playlist before the media.
val idx = PlaylistSelection.recheckIndex(items.size, currentIndex, hasContentOnScreen) { playableNow(it) }
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
}
}

View file

@ -31,6 +31,39 @@ object PlaylistSelection {
return -1
}
/**
* First playable index AT OR AFTER [from] (wrapping), or -1 if none. A negative [from] means
* "no position yet" and starts at 0 rather than wrapping onto the last item.
*/
fun playableFromIndex(size: Int, from: Int, isPlayable: (Int) -> Boolean): Int {
if (size <= 0) return -1
val start = if (from < 0) 0 else from % size
for (i in 0 until size) {
val idx = (start + i) % size
if (isPlayable(idx)) return idx
}
return -1
}
/**
* Which item a content re-check should play once something finally becomes ready.
*
* [hasContentOnScreen] is the entire distinction, and getting it wrong costs the operator the
* first item of their playlist. When content IS up, currentIndex is a real position that has
* already had its turn, so the scan must move PAST it. When nothing is up, currentIndex is only
* where playback INTENDED to begin updatePlaylist() seeds it to 0 for a playlist that has not
* started yet so it has never been shown, and advancing past it silently drops item 1 from the
* first pass through the playlist.
*
* That is the cold-start case on every fresh panel: the playlist arrives before its media has
* downloaded, start() finds nothing playable, and the re-check three seconds later is what
* actually begins playback. On a two-item playlist it looks exactly like "only one of the two
* ever plays" until the list wraps.
*/
fun recheckIndex(size: Int, from: Int, hasContentOnScreen: Boolean, isPlayable: (Int) -> Boolean): Int =
if (hasContentOnScreen) nextPlayableIndex(size, from, isPlayable)
else playableFromIndex(size, from, isPlayable)
enum class NonePlayable { KEEP_CURRENT, SHOW_WAITING }
/**

View file

@ -66,16 +66,25 @@ class ScreenshotCapture {
if (tv.isAvailable && tv.visibility == View.VISIBLE) {
val tvBitmap = tv.bitmap
if (tvBitmap != null) {
val loc = IntArray(2)
tv.getLocationInWindow(loc)
val rootLoc = IntArray(2)
view.getLocationInWindow(rootLoc)
val x = (loc[0] - rootLoc[0]).toFloat()
val y = (loc[1] - rootLoc[1]).toFloat()
val destRect = Rect(x.toInt(), y.toInt(), x.toInt() + tv.width, y.toInt() + tv.height)
canvas.drawBitmap(tvBitmap, null, destRect, null)
// Place the frame through the SAME transform chain the hierarchy was drawn
// with, rather than an axis-aligned rect at getLocationInWindow().
//
// #236 gave a video-wall panel a mounting rotation, which puts a real
// rotation on an ancestor of this TextureView. An axis-aligned rect cannot
// express that, so the frame was pasted un-rotated at a position that fell
// outside the capture bitmap entirely — and what the dashboard received was
// the plain black that view.draw() leaves wherever a TextureView is, i.e. a
// panel that looks dead while it is happily playing. Verified on the
// emulator: at rotation 90 every remote screenshot of a video came back
// #010101 with zero variance, while rotation 0 was correct.
val m = matrixTo(tv, view)
// The surface bitmap is not required to match the view's size.
if (tvBitmap.width > 0 && tvBitmap.height > 0) {
m.preScale(tv.width.toFloat() / tvBitmap.width, tv.height.toFloat() / tvBitmap.height)
}
canvas.drawBitmap(tvBitmap, m, null)
tvBitmap.recycle()
Log.d("ScreenshotCapture", "Composited TextureView at ($x,$y) size=${tv.width}x${tv.height}")
Log.d("ScreenshotCapture", "Composited TextureView ${tv.width}x${tv.height} via $m")
}
}
}
@ -114,6 +123,28 @@ class ScreenshotCapture {
}
}
/**
* The matrix mapping [view]'s own coordinates into [ancestor]'s, by walking up the parent chain
* and concatenating each step the way the framework does when it draws a child: the child's own
* matrix (rotation/scale/translation about its pivot) followed by its layout offset.
*
* Stops at [ancestor], or at the top of the View chain if it is never reached a partial chain
* still places the frame better than ignoring the transform completely.
*/
private fun matrixTo(view: View, ancestor: View): android.graphics.Matrix {
val out = android.graphics.Matrix()
var v: View = view
while (true) {
val local = android.graphics.Matrix(v.matrix) // translationX/Y + rotation about pivot
local.postTranslate(v.left.toFloat(), v.top.toFloat())
out.postConcat(local) // out = local * out (child-first)
val parent = v.parent
if (parent !is View || parent === ancestor) break
v = parent
}
return out
}
private fun findAllTextureViews(view: View, result: MutableList<TextureView>) {
if (view is TextureView) {
result.add(view)

View file

@ -47,6 +47,18 @@ class UpdateChecker(private val context: Context) {
// class is the imperative shell that persists state and does the download/install.
var otaLogReporter: ((level: String, message: String) -> Unit)? = null
/*
* Why the last download/verify attempt failed, in specific terms.
*
* The caller could only ever say "failed to download or failed signature verification", which
* covers SEVEN distinct branches three of them download failures where verification never
* runs at all. Every specific reason went to logcat, which an unprivileged app UID cannot read
* on Android 9, so in the field the message was unactionable: it named a symptom shared by
* unrelated causes and pointed at the wrong half of the code as often as the right one.
* Diagnosing one occurrence took an evening. This makes the next one a sentence.
*/
private var lastFailure: String? = null
private fun report(level: String, message: String) {
when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) }
try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {}
@ -272,7 +284,7 @@ class UpdateChecker(private val context: Context) {
// Unforced this is deliberately quiet (transient network blips are not news). Forced,
// somebody is waiting on an answer, and "the APK would not download or did not match
// our signing key" is the single most useful thing we can tell them.
if (forced) report("error", "Force update: $latestVersion failed to download or failed signature verification — not installed")
if (forced) report("error", "Force update: $latestVersion not installed — ${lastFailure ?: "reason unavailable"}")
return
}
@ -311,15 +323,99 @@ class UpdateChecker(private val context: Context) {
// Returns TRUE only when a verified APK is in hand and an install has been launched (the
// caller may then count an attempt); FALSE on any download/verify failure — the caller must
// NOT count those, so a transient network problem can't burn a healthy device's budget. #139
/*
* Where a downloaded APK is staged.
*
* getExternalFilesDir() returns NULL whenever external storage is not mounted/available and
* on a signage panel that is not exotic: no emulated volume, a vendor ROM that never mounts one,
* an SD card ejected, storage still unmounted early in boot.
*
* The bug this replaces: `File(context.getExternalFilesDir(...), name)`. Java's File(File,String)
* treats a NULL parent as "no parent" and silently produces a RELATIVE path, so the download
* targeted `ScreenTinker-x.y.z.apk` in the process working directory `/` which is not
* writable. The write threw, the generic catch swallowed it, and the caller reported only
* "failed to download or failed signature verification". Nothing was ever written, so there was
* no partial file to find and nothing in the message pointed at storage. It fails on EVERY
* attempt, forever, on an affected panel and identically for the pushed-APK path, which had
* the same line.
*
* Internal storage always exists, so fall back to it. It costs nothing when external is present.
* NOTE: the intent-based install fallback resolves this file through FileProvider, so
* res/xml/file_paths.xml must expose this directory too see the <files-path> entry there.
*/
/*
* Where to stage a downloaded APK the FIRST location that actually accepts bytes.
*
* Internal app storage is tried first and is effectively guaranteed: /data/data/<pkg>/files is
* this app's own private directory, always mounted, always writable. If it is not, the app is
* not running. External storage is only a convenience (it survives uninstall and is visible for
* a manual install), and it is the one that fails it can be absent, unmounted, present but
* unwritable, or report canWrite() = true and then refuse the write anyway.
*
* Each candidate is PROVEN with a real write, not asked. The previous version asked
* canWrite(), believed the answer, and then died at outputStream() before a single byte so
* the update failed instantly and reported it as a download problem. Every fallback in the world
* is useless if the first choice is trusted rather than tested.
*
* Returns the directory, or null with every reason it could not find one, so the operator gets
* the full picture instead of the first excuse.
*/
private fun apkStagingDir(needBytes: Long): Pair<File?, String> {
val candidates = LinkedHashMap<String, File>()
candidates["internal"] = File(context.filesDir, "Download")
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.let { candidates["external"] = it }
candidates["cache"] = File(context.cacheDir, "Download")
candidates["files"] = context.filesDir // last resort: no subdirectory to create
val reasons = StringBuilder()
for ((name, dir) in candidates) {
val problem = apkDirProblem(dir, needBytes)
if (problem == null) {
if (name != "internal") Log.w(TAG, "Staging APK in $name (${dir.absolutePath})")
return dir to name
}
if (reasons.isNotEmpty()) reasons.append("; ")
reasons.append("$name ${problem}")
}
return null to reasons.toString()
}
private fun apkDirProblem(dir: File, needBytes: Long): String? {
if (!dir.exists() && !dir.mkdirs()) return "cannot create ${dir.absolutePath}"
if (!dir.isDirectory) return "${dir.absolutePath} is not a directory"
if (!dir.canWrite()) return "no write permission on ${dir.absolutePath}"
val free = try { dir.usableSpace } catch (_: Throwable) { -1L }
// Headroom, not an exact fit: the installer stages its own copy of the APK as well, so a
// volume with barely the download's worth free still fails at install time.
if (needBytes > 0 && free in 0 until (needBytes * 2)) {
return "only ${free / 1024 / 1024}MB free on ${dir.absolutePath}, need ~${needBytes * 2 / 1024 / 1024}MB"
}
// Prove it rather than infer it: canWrite() can be true on a volume that refuses the write.
return try {
val probe = File(dir, ".st-write-probe")
probe.writeBytes(byteArrayOf(1))
probe.delete()
null
} catch (e: Throwable) {
"write test failed in ${dir.absolutePath}: ${e.javaClass.simpleName} ${e.message}"
}
}
private fun downloadAndInstall(url: String, version: String): Boolean {
try {
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"ScreenTinker-$version.apk")
// Find somewhere that will actually take the file, before asking the network for it.
val (dir, whereOrWhy) = apkStagingDir(9L * 1024 * 1024)
if (dir == null) {
lastFailure = "nowhere to stage the update — $whereOrWhy"
Log.e(TAG, "APK staging unavailable: $whereOrWhy")
return false
}
val apkFile = File(dir, "ScreenTinker-$version.apk")
// #139: reuse a previously-downloaded, verified APK for this version instead of
// re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a
// manual install when silent install isn't possible.
if (apkFile.exists() && verifyApkSignature(apkFile)) {
if (apkFile.exists() && cachedApkIs(apkFile, version) && verifyApkSignature(apkFile)) {
Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
handler.post { installApk(apkFile) }
return true
@ -332,6 +428,7 @@ class UpdateChecker(private val context: Context) {
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
lastFailure = "server returned HTTP ${response.code} for the APK"
Log.e(TAG, "Download failed: ${response.code}")
return false
}
@ -351,7 +448,21 @@ class UpdateChecker(private val context: Context) {
// Verify the downloaded APK is our package AND signed by the same key as
// the currently-installed app before installing. An attacker can't forge
// our signature, so this holds even over an untrusted transport.
// The server advertises a version and separately serves a file; the two can drift. A
// stale APK behind a current version number installs as a NO-OP, so the version never
// changes, the update is attempted again, and the panel loops until its attempts are
// spent — reporting a download failure, which it is not. Say what actually happened.
if (!cachedApkIs(apkFile, version)) {
val got = apkVersionName(apkFile) ?: "unreadable"
lastFailure = "server served $got but advertised $version — the update on the server is stale"
Log.e(TAG, "Version mismatch: advertised $version, downloaded $got")
apkFile.delete()
return false
}
if (!verifyApkSignature(apkFile)) {
// lastFailure was set precisely inside verifyApkSignature; keep it, and add the
// size so a truncated download is distinguishable from a genuine cert mismatch.
lastFailure = "${lastFailure ?: "signature verification failed"} (downloaded ${apkFile.length()} bytes)"
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
apkFile.delete()
return false
@ -364,6 +475,7 @@ class UpdateChecker(private val context: Context) {
}
return true
} catch (e: Exception) {
lastFailure = "download/install threw ${e.javaClass.simpleName}: ${e.message}"
Log.e(TAG, "Download/install error: ${e.message}")
return false
}
@ -378,7 +490,9 @@ class UpdateChecker(private val context: Context) {
try {
val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" }
val fileName = "pushed-" + (if (base.endsWith(".apk")) base else "$base.apk")
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
val (dir, whyNot) = apkStagingDir(9L * 1024 * 1024)
if (dir == null) { Log.e(TAG, "installFromUrl: nowhere to stage — $whyNot"); return@Thread }
val apkFile = File(dir, fileName)
if (apkFile.exists()) apkFile.delete()
val response = client.newCall(Request.Builder().url(url).build()).execute()
if (!response.isSuccessful) { Log.e(TAG, "installFromUrl: download failed ${response.code}"); return@Thread }
@ -482,6 +596,52 @@ class UpdateChecker(private val context: Context) {
// True only if the downloaded APK is this same package and shares a signing
// certificate with the installed app. Fail-closed on any error.
/* The versionName inside an APK file, or null if it cannot be read. */
private fun apkVersionName(apkFile: File): String? = try {
context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)?.versionName
} catch (e: Throwable) {
Log.w(TAG, "Could not read version from ${apkFile.name}: ${e.message}")
null
}
/*
* Is this file actually the version we mean to install?
*
* The cache is keyed by FILENAME, and the filename is built from the version the server
* advertised so a file called ScreenTinker-1.9.34.apk containing 1.9.33 passes a signature
* check (same key), gets reused on every attempt, and installs as a no-op forever. Fixing the
* server does not clear it; only deleting the file does. Checking the version inside makes that
* self-healing instead of needing a hand on the device.
*/
private fun cachedApkIs(apkFile: File, version: String): Boolean {
val got = apkVersionName(apkFile) ?: return false
if (got == version) return true
Log.w(TAG, "Cached ${apkFile.name} contains $got, expected $version — discarding")
return false
}
/*
* Delete every staged APK. The escape hatch for a panel holding a bad download: it forces the
* next check to fetch again rather than reuse. Safe at any time these are only ever caches,
* re-fetched on demand.
*/
fun clearUpdateCache(): Int {
var n = 0
for (dir in listOfNotNull(
File(context.filesDir, "Download"),
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
File(context.cacheDir, "Download"),
)) {
val files = try { dir.listFiles() } catch (_: Throwable) { null } ?: continue
for (f in files) {
if (!f.name.endsWith(".apk")) continue
if (f.delete()) n++
}
}
report("info", "Update cache cleared ($n file(s)) — the next check will download afresh")
return n
}
private fun verifyApkSignature(apkFile: File): Boolean {
return try {
val pm = context.packageManager
@ -498,10 +658,12 @@ class UpdateChecker(private val context: Context) {
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags)
if (downloaded == null) {
lastFailure = "the downloaded file could not be parsed as an APK (truncated or not an APK)"
Log.e(TAG, "Could not parse downloaded APK")
return false
}
if (downloaded.packageName != context.packageName) {
lastFailure = "APK is package ${downloaded.packageName}, expected ${context.packageName}"
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
return false
}
@ -511,18 +673,37 @@ class UpdateChecker(private val context: Context) {
val installedFlags = if (installedUsesSigningInfo)
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
val installed = pm.getPackageInfo(context.packageName, installedFlags)
val downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
var downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
// #139 follow-up: on API 28/29 the archive read goes through the legacy GET_SIGNATURES
// path, and if PackageManager hands back nothing we previously refused a perfectly good
// APK with no way to tell that apart from a real mismatch. Read the v1 signature
// ourselves before giving up — JarFile is random-access, which is how the JAR signature
// is meant to be read, and it verifies the same bytes PackageManager would have.
// This does NOT weaken the check: the cert extracted here is still compared against the
// installed app's below, and an unsigned or differently-signed APK still fails.
if (downloadedSigs.isEmpty()) {
val viaJar = archiveCertsViaJar(apkFile)
if (viaJar.isNotEmpty()) {
Log.w(TAG, "Archive certs unreadable via PackageManager on API ${Build.VERSION.SDK_INT}; used JarFile (${viaJar.size})")
downloadedSigs = viaJar
}
}
val installedSigs = signingCertHashes(installed, installedUsesSigningInfo)
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
lastFailure = "could not read signing certificates (archive=${downloadedSigs.size}, installed=${installedSigs.size}) on API ${Build.VERSION.SDK_INT}"
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
return false
}
// Require a non-empty overlap of signer certs (handles multi-signer / cert-rotation
// the same way the API>=30 path does: compare the full current signer sets).
val match = downloadedSigs.any { it in installedSigs }
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
if (!match) {
lastFailure = "APK is signed by a different key than the installed app"
Log.e(TAG, "APK signing certificate does not match installed app")
}
match
} catch (e: Exception) {
lastFailure = "signature check threw ${e.javaClass.simpleName}: ${e.message}"
Log.e(TAG, "Signature verification error: ${e.message}", e)
false
}
@ -533,6 +714,31 @@ class UpdateChecker(private val context: Context) {
// multi-signer + rotation aware), GET_SIGNATURES -> legacy .signatures (the only field
// populated for ARCHIVE reads on API 28/29). Both yield the same cert for a normally-signed
// APK; the caller compares as sets so an overlapping signer still verifies.
/*
* Read the APK's v1 (JAR) signer certificates directly, as a fallback for the API 28/29 archive
* read. Opening JarFile with verify=true and reading an entry to completion is what populates
* JarEntry.certificates the certificate is only known once the bytes it covers have been
* checked, so the read is the verification, not a step before it.
*
* Returns an empty set on any problem, which leaves the caller refusing the install: this is a
* fallback for "PackageManager told us nothing", never a way to skip the comparison.
*/
private fun archiveCertsViaJar(apkFile: File): Set<String> {
return try {
java.util.jar.JarFile(apkFile, true).use { jar ->
val entry = jar.getJarEntry("AndroidManifest.xml") ?: return emptySet()
jar.getInputStream(entry).use { input ->
val buf = ByteArray(8192)
while (input.read(buf) != -1) { /* must read fully before certificates populate */ }
}
entry.certificates?.mapNotNull { sha256(it.encoded) }?.toSet() ?: emptySet()
}
} catch (e: Throwable) {
Log.w(TAG, "JarFile cert read failed: ${e.message}")
emptySet()
}
}
private fun signingCertHashes(info: PackageInfo, useSigningInfo: Boolean): Set<String> {
val sigs: Array<Signature>? = if (useSigningInfo) {
info.signingInfo?.apkContentsSigners

View file

@ -35,6 +35,10 @@ class DeviceInfo(private val context: Context) {
// ISP's address as their screen's IP. Needs no permission — read straight off the
// interfaces, so it works on Ethernet panels too, not just Wi-Fi.
put("local_ip", getLocalIp() ?: JSONObject.NULL)
// Both stacks, not one: getLocalIp() filters to Inet4Address, so a v6-only panel
// reported NOTHING and the dashboard showed a dash for a screen that had a perfectly
// good address. A dual-stack panel now shows both.
put("local_ip6", getLocalIp6() ?: JSONObject.NULL)
put("wifi_rssi", getWifiRSSI())
put("uptime_seconds", getUptimeSeconds())
// #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator)
@ -221,6 +225,41 @@ class DeviceInfo(private val context: Context) {
found
} catch (e: Throwable) { null }
/**
* The panel's own IPv6 address, reported alongside the v4 one rather than instead of it
* a dual-stack screen has both and an operator may need either.
*
* Deliberately NOT link-local (fe80::/10). Every interface has one, they are the addresses
* most likely to be enumerated first, and none of them can be dialled without also knowing
* the zone index so putting one in the dashboard would fill the field with a string that
* cannot be pasted anywhere useful and hide the address that can. A global or unique-local
* address is the one someone reaching the panel on site actually needs.
*
* The scope check also drops multicast and the unspecified address; what survives is a
* routable unicast address. `hostAddress` can carry a %iface suffix on some builds, so it is
* trimmed the field is for humans and for pasting into a browser.
*/
private fun getLocalIp6(): String? = try {
var found: String? = null
val ifaces = java.net.NetworkInterface.getNetworkInterfaces()
while (ifaces != null && ifaces.hasMoreElements() && found == null) {
val iface = ifaces.nextElement()
if (!iface.isUp || iface.isLoopback) continue
val addrs = iface.inetAddresses
while (addrs.hasMoreElements()) {
val addr = addrs.nextElement()
if (addr is java.net.Inet6Address &&
!addr.isLoopbackAddress && !addr.isLinkLocalAddress &&
!addr.isAnyLocalAddress && !addr.isMulticastAddress
) {
found = addr.hostAddress?.substringBefore('%')
break
}
}
}
found
} catch (e: Throwable) { null }
@Suppress("DEPRECATION")
private fun getWifiRSSI(): Int {
return try {

View file

@ -52,6 +52,27 @@ object PlayerCapabilities {
// Native view rotation: the ExoPlayer surface sits inside the rotated view, so video
// turns with the graphics. No hardware-plane problem here.
"display.rotation",
// Per-window overlay dim (WindowManager.LayoutParams.screenBrightness) — Tier 0, no
// permission, works at any privilege level. Distinct from "system.brightness" below,
// which writes the system-wide setting and DOES need WRITE_SETTINGS or owner.
// Declared here because the android BASELINE already grants it: without this line an
// updated panel replaces the baseline with a declared set that lacks it, and LOSES
// the dim slider it had before it updated.
"display.brightness",
// Capture, at ANY privilege level. captureScreen() is a three-rung fallback —
// MediaProjection (system-wide, needs consent), then the accessibility screenshot
// API, then ScreenshotCapture.captureView, which is a plain view draw with no
// permission check of any kind. The last rung is narrower than the others (the
// player's own window, foreground only) but on a kiosk panel that window IS the
// content, so the operator gets a picture rather than a refusal.
//
// Declared unconditionally for the same reason display.brightness is: the android
// BASELINE grants both, and a declared set replaces the baseline rather than
// merging with it. Gating on accessibility meant a panel LOST live view and
// screenshots by updating, while the fallback that still served them kept working.
// It also made granting MediaProjection invisible — consent was given, capture
// genuinely started, and the server went on refusing because nothing re-declared.
"remote.screenshot", "remote.stream",
// Input is plain view dispatch and works regardless of privilege.
"remote.input",
// The player restarts itself; the OTA checker updates the APK.
@ -67,10 +88,6 @@ object PlayerCapabilities {
// ---- conditional on runtime state -------------------------------------------------------
// Full-screen capture needs the accessibility service; without it capture falls back to
// the app's own view. Declared only for the real thing, per the capability contract.
if (accessibility) caps += listOf("remote.screenshot", "remote.stream")
// Display power is asymmetric and only honest when BOTH halves exist. screen_off needs
// owner, device-admin FORCE_LOCK, or accessibility; screen_on now works anywhere via a
// wake lock (WAKE_LOCK is a normal permission). So the binding constraint is the OFF
@ -86,6 +103,13 @@ object PlayerCapabilities {
// confirmation — unusable on a panel with no input, so not claimed.
if (isOwner) caps += "system.kiosk"
// The privilege itself, declared as a capability. Every #161 Tier-2 command
// (lock_now / power_menu / status_bar / block_uninstall / unblock_uninstall) gates on
// this name. No player declared it, so the server accepts "system.kiosk" as a stand-in —
// exact, because kiosk is itself owner-only, but a stand-in nonetheless. Declaring the
// canonical name makes those refusals say what they mean and lets the stand-in retire.
if (isOwner) caps += "system.device_owner"
// Owner-only clock control.
if (isOwner) caps += "system.time"

View file

@ -2,4 +2,11 @@
<paths>
<external-files-path name="downloads" path="Download/" />
<external-files-path name="apk" path="." />
<!-- UpdateChecker.apkDir() stages APKs in internal storage when external storage is not
available (getExternalFilesDir returns null). The silent PackageInstaller path streams the
file itself and needs nothing here, but the intent-based install FALLBACK resolves it
through FileProvider — without this entry that fallback throws
IllegalArgumentException ("Failed to find configured root"), turning an already-degraded
panel into one that cannot install at all. -->
<files-path name="internal_downloads" path="Download/" />
</paths>

View file

@ -57,4 +57,49 @@ class PlaylistSelectionTest {
@Test fun `a single downloaded item loops instead of blanking`() {
assertEquals(0, PlaylistSelection.nextPlayableIndex(1, 0, readyPredicate(0)))
}
// ===== the cold-start re-check: item 1 of the playlist must not be skipped =====
//
// Observed on the emulator: a freshly paired panel gets its playlist BEFORE the media has
// downloaded, so start() finds nothing playable and the 3-second content re-check is what
// actually begins playback. updatePlaylist() has already seeded currentIndex = 0, and the
// re-check used to advance PAST it — so a 4-item playlist played 1,2,3,0 on its first pass and
// a 2-item playlist looked like "only one of the two ever plays".
@Test fun `a cold-start re-check begins at the seeded index instead of skipping past it`() {
// currentIndex seeded to 0, nothing on screen yet, everything now downloaded.
assertEquals("item 0 has never played — it must not be skipped", 0,
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate(0, 1, 2, 3)))
}
@Test fun `a re-check with content already on screen still advances past the current item`() {
// The other half of the rule: a real position has had its turn, so we must move on.
assertEquals(1,
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = true, isPlayable = readyPredicate(0, 1, 2, 3)))
}
@Test fun `a cold-start re-check still skips an item whose content is not downloaded`() {
// Item 0 is still downloading; the panel starts on the first item it can actually show.
assertEquals(2,
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate(2, 3)))
}
@Test fun `a cold-start re-check with no position yet starts at the top, not the last item`() {
// currentIndex is -1 before updatePlaylist seeds it; wrapping onto the last item here would
// start a fresh panel at the END of its playlist.
assertEquals(0,
PlaylistSelection.recheckIndex(3, from = -1, hasContentOnScreen = false, isPlayable = readyPredicate(0, 1, 2)))
}
@Test fun `a cold-start re-check returns -1 while nothing is downloaded`() {
assertEquals(-1,
PlaylistSelection.recheckIndex(3, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate()))
}
@Test fun `playableFromIndex is inclusive of its start and wraps`() {
assertEquals(1, PlaylistSelection.playableFromIndex(4, 1, readyPredicate(1, 3)))
assertEquals(3, PlaylistSelection.playableFromIndex(4, 2, readyPredicate(1, 3)))
assertEquals(1, PlaylistSelection.playableFromIndex(4, 3, readyPredicate(1))) // wraps
assertEquals(-1, PlaylistSelection.playableFromIndex(0, 0, readyPredicate(0)))
}
}

45
android/licenses.json Normal file
View file

@ -0,0 +1,45 @@
{
"_comment": [
"Licence policy for everything on the Android release runtime classpath — i.e. everything that",
"can end up inside the APK customers install.",
"",
"scripts/android-license-check.js resolves the real classpath and checks it against this file.",
"An artifact that appears in neither 'artifacts' nor 'groups' FAILS: a new transitive dependency",
"must be looked at by a person before it ships, which is exactly how org.json:json:20090211 got",
"into the APK unnoticed in the first place.",
"",
"Record what you verified in 'evidence' — the point is to be able to answer 'how do you know?'"
],
"groups": {
"androidx": { "license": "Apache-2.0", "evidence": "AndroidX / Jetpack, Apache-2.0 across the board" },
"com.google.android.material": { "license": "Apache-2.0", "evidence": "Material Components for Android" },
"com.google.code.gson": { "license": "Apache-2.0", "evidence": "google/gson LICENSE" },
"com.google.crypto.tink": { "license": "Apache-2.0", "evidence": "google/tink LICENSE" },
"com.google.errorprone": { "license": "Apache-2.0", "evidence": "google/error-prone LICENSE" },
"com.google.guava": { "license": "Apache-2.0", "evidence": "google/guava LICENSE" },
"com.google.j2objc": { "license": "Apache-2.0", "evidence": "google/j2objc LICENSE" },
"com.squareup.okhttp3": { "license": "Apache-2.0", "evidence": "square/okhttp LICENSE" },
"com.squareup.okio": { "license": "Apache-2.0", "evidence": "square/okio LICENSE" },
"org.checkerframework": { "license": "MIT", "evidence": "checker-framework, MIT for the qualifiers" },
"org.jetbrains": { "license": "Apache-2.0", "evidence": "JetBrains annotations" },
"org.jetbrains.kotlin": { "license": "Apache-2.0", "evidence": "Kotlin stdlib" },
"org.jetbrains.kotlinx": { "license": "Apache-2.0", "evidence": "kotlinx coroutines" },
"io.socket": { "license": "MIT", "evidence": "socket.io-client-java LICENSE (MIT)" }
},
"artifacts": {},
"denied": {
"org.json:json": {
"why": "JSON Licence — the 'shall be used for Good, not Evil' clause. Not OSI-approved, non-free per Debian and Fedora, Apache Category X. Arrives transitively via socket.io-client and was previously packaged into the APK in full (19 classes). Excluded in app/build.gradle.kts; Android provides org.json in the platform from API 1 and minSdk is 24, so nothing is lost."
}
},
"denied_licenses": [
{ "match": "AGPL", "why": "network copyleft" },
{ "match": "GPL", "why": "strong copyleft in a commercially distributed binary" },
{ "match": "SSPL", "why": "not OSI-approved, service-scope obligations" },
{ "match": "JSON", "why": "field-of-use restriction" }
]
}

View file

@ -30,7 +30,25 @@
' So ask the filesystem instead of assuming: whichever volume holds this script is the volume
' that holds everything else beside it.
Function StorageRoot() As String
' Which volume are we actually running from? Everything else is derived from this — the offline
' page, the widget's storage directory, the self-update paths — so getting it wrong points the
' whole player at a volume that may not physically exist.
'
' Probed in the order the OS itself searches for an autorun script (roStorageHotplug.GetStorages()
' documents ["USB1:/", "SD:/", "SD2:/", "SSD:/", "FLASH:/"]), so the answer matches the volume the
' player actually booted from. FLASH is last because it is the fallback of last resort: the unit
' this was developed on has a dead card slot and boots from internal flash, and an earlier version
' of this function knew only FLASH and SD — so fitting real storage to that player and moving the
' files onto it would have silently resolved every path to "SD:", a slot with nothing in it.
'
' ReadFile rather than a MatchFiles existence check: MatchFiles takes a DIRECTORY plus a pattern
' and returns nothing when the pattern contains a separator, which is why the helper further down
' this file never finds anything.
ba = CreateObject("roByteArray")
if ba.ReadFile("USB1:/autorun.brs") then return "USB1:"
if ba.ReadFile("SSD:/autorun.brs") then return "SSD:"
if ba.ReadFile("SD:/autorun.brs") then return "SD:"
if ba.ReadFile("SD2:/autorun.brs") then return "SD2:"
if ba.ReadFile("FLASH:/autorun.brs") then return "FLASH:"
return "SD:"
End Function
@ -82,6 +100,41 @@ Function LoadConfig() As Object
return cfg
End Function
Function SnapshotDir() As String
' DWS writes to ITS primary storage, which is not necessarily the volume the presentation
' booted from — so probe rather than assume, in the same order StorageRoot() does.
for each v in ["USB1:", "SSD:", "SD:", "SD2:", "FLASH:"]
d$ = v + "/remote_snapshots"
files = MatchFiles(d$, "*.jpg")
if files <> invalid and files.Count() > 0 then return d$
end for
return ""
End Function
Function NewestFile(dir As String, pattern As String) As String
' DWS names captures img-YYYY-MM-DD-HH-MM-SS.jpg, so the lexicographic maximum IS the newest.
best$ = ""
files = MatchFiles(dir, pattern)
if files = invalid then return ""
for each f in files
if f > best$ then best$ = f
end for
return best$
End Function
Function DwsPort() As String
' Which port the local Diagnostic Web Server answers on. Read from the same registry the
' DWS itself is configured from, so a player moved off port 80 still gets framebuffer
' captures instead of silently degrading to the canvas path.
port$ = "80"
reg = CreateObject("roRegistrySection", "networking")
if reg <> invalid and reg.Exists("http_server") then
v$ = reg.Read("http_server").Trim()
if v$ <> "" then port$ = v$
end if
return port$
End Function
Sub SaveRegistry(key As String, value As String)
reg = CreateObject("roRegistrySection", "screentinker")
reg.Write(key, value)
@ -108,15 +161,31 @@ End Function
Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object) As Object
config = {
url: url
nodejs_enabled: true ' Node runtime inside the widget
brightsign_js_objects_enabled: true ' REQUIRED for require("@brightsign/*") — without
' this the bridge silently degrades to no-ops and
' the player loses identity AND restart delegation
' THIS is what gates require("@brightsign/*"). Without it the bridge silently degrades to
' no-ops and the player loses identity AND restart delegation. ("BrightSign modules are
' actually part of the firmware, but in terms of usage they are identical to other Node.js
' modules" — so no Node runtime means no modules.)
nodejs_enabled: true
' NOT what gates require(). This flag enables the LEGACY GLOBAL objects — BSDeviceInfo,
' BSMessagePort and friends — which this bridge does not use; BrightSign's own cookbook
' examples call require("@brightsign/bt") with nodejs_enabled alone. Kept set because
' several of their samples set both and it costs nothing, but the comment that used to sit
' here credited it with holding the whole bridge up, which would send the next person
' debugging a dead bridge to exactly the wrong line.
brightsign_js_objects_enabled: true
javascript_enabled: true
security_params: { websecurity: true }
hwz_default: "on" ' hardware z-order — video on its own plane
storage_path: "/cache" ' DIRECTORY NAME for the local storage cache
storage_quota: "1073741824" ' 1GB, as a STRING — service-worker offline cache
' An ABSOLUTE path on the volume we booted from. "/cache" carries no BrightSign drive
' specifier, so it resolves outside the writable volumes and the widget's local storage —
' the backing store a service worker, the Cache API and IndexedDB all need — has nowhere to
' persist to. The XT245 on alpha exposes navigator.serviceWorker and then refuses to
' register one, which is exactly what a widget with no usable storage would do.
storage_path: StorageRoot() + "/cache" ' local storage, on the volume we booted from
' 1GB, as a DOUBLE. The docs are explicit: "A BrightScript integer is only guaranteed to be
' able to represent a count of bytes up to 2GB so avoid using integers... Use float or double
' instead... (string can also be used but is not recommended)". This was a string.
storage_quota: 1073741824.0
port: port
mouse_enabled: false
}
@ -170,7 +239,13 @@ Sub TakeSnapshot(widget As Object, req As Object)
if req <> invalid and req.width <> invalid then w% = req.width
if req <> invalid and req.height <> invalid then h% = req.height
body$ = "{""width"":" + Stri(w%).Trim() + ",""height"":" + Stri(h%).Trim() + "}"
' BrightScript has NO escape sequences in string literals: "" does not mean an escaped quote,
' it ends one string and begins another, so `"{""width"":"` is three literals with no operator
' between them — a compile error that stops the WHOLE SCRIPT loading, not just this function.
' A quote has to come from Chr(34). This line is why the player booted to nothing:
' ScriptLoadError: Syntax Error. (compile error &h02) in SSD:/autorun.brs(196)
q$ = Chr(34)
body$ = "{" + q$ + "width" + q$ + ":" + Stri(w%).Trim() + "," + q$ + "height" + q$ + ":" + Stri(h%).Trim() + "}"
ut = CreateObject("roUrlTransfer")
if ut = invalid then
@ -178,36 +253,67 @@ Sub TakeSnapshot(widget As Object, req As Object)
return
end if
ut.SetUrl("http://localhost/api/v1/snapshot/")
' The DWS port is NOT always 80. It is configurable and BSN/Supervisor-provisioned players
' are commonly moved off it — the unit this was found on serves DWS on 8080 with nothing
' listening on 80 at all. Hardcoding 80 meant every host snapshot failed to connect, fell
' through to the in-page canvas, and the canvas cannot read the hardware video plane, so the
' operator got a card reading "Video is playing on the hardware plane and cannot be captured"
' while the very same capture worked perfectly from the DWS Snapshots tab.
'
' The port lives in the networking registry section as http_server; absent means the default.
' 127.0.0.1, NOT "localhost". A name has to be resolved, and on this platform that resolution
' is not ours to rely on: if it answers ::1 first the connection goes to an address the DWS is
' not listening on and the transfer sits there until something times out — which is exactly the
' shape of the failure this chased (the page gave up at 15s having heard nothing at all, not
' even this Sub's own timeout). A literal address cannot be resolved wrongly.
ut.SetUrl("http://127.0.0.1:" + DwsPort() + "/api/v1/snapshot/")
ut.SetUserAndPassword("admin", serial$)
ut.AddHeader("Content-Type", "application/json")
resp$ = ut.PostFromStringWithRetry(body$, 1)
if resp$ = invalid or resp$ = "" then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no response from the local DWS" })
' SYNCHRONOUS, deliberately — and this is the whole fix.
'
' PostFromStringWithRetry does not exist (calling it raised "Member function not found" from
' inside the event loop, i.e. a snapshot request took the whole player down). The obvious
' alternative, AsyncPostFromString + Wait on a private port, is the documented way to read a
' POST body — and on this hardware its roUrlEvent NEVER ARRIVES. The Sub simply sat in Wait
' while st-bridge.js gave up at 15s, so the page reported "host did not answer in time" and
' fell back to the canvas, which cannot read the hardware video plane. Every other transfer in
' this file is synchronous (GetToString for the package check, GetToFile for the download) and
' every one of them works, including the self-update that replaced this very script.
'
' PostFromString returns only the response CODE and discards the body — which would normally
' lose the thumbnail. It does not matter here: DWS WRITES THE CAPTURE TO PRIMARY STORAGE before
' it answers (the body carries a `filename` pointing at it), so the file is on disk by the time
' the call returns and can simply be read back.
code% = ut.PostFromString(body$)
if code% <> 200 then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS refused the capture (HTTP " + Stri(code%).Trim() + " on port " + DwsPort() + ")" })
return
end if
json = ParseJson(resp$)
if json = invalid or json.data = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "unparseable DWS response" })
dir$ = SnapshotDir()
if dir$ = "" then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS wrote no capture to any volume" })
return
end if
if json.data.error <> invalid then
' e.g. "No primary storage found." — pass the player's own words through; inventing a
' friendlier message here would hide the one fact that explains the failure.
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: json.data.error.message })
newest$ = NewestFile(dir$, "*.jpg")
if newest$ = "" then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no capture found in " + dir$ })
return
end if
r = json.data.result
if r = invalid or r.remotesnapshotthumbnail = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS returned no thumbnail" })
ba2 = CreateObject("roByteArray")
if not ba2.ReadFile(dir$ + "/" + newest$) then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "could not read " + newest$ })
return
end if
widget.PostJSMessage({ type: "snapshot-result", ok: true, image: r.remotesnapshotthumbnail })
' Read, hand over, then remove: DWS appends a new file per capture and nothing else prunes
' them, so a 1fps remote-control stream would otherwise fill the volume.
img$ = "data:image/jpeg;base64," + ba2.ToBase64String()
DeleteFile(dir$ + "/" + newest$)
widget.PostJSMessage({ type: "snapshot-result", ok: true, image: img$ })
End Sub
' Rotate the OUTPUT, not the DOM.
@ -235,18 +341,55 @@ Sub SetOrientation(widget As Object, o As String)
return
end if
mode$ = vm.GetMode()
if mode$ = invalid or mode$ = "" then mode$ = "1920x1080x60p"
ok = vm.SetMode(mode$, transform$)
if ok = invalid then ok = false
if ok then
print "[st] orientation "; o; " -> transform "; transform$
else
print "[st] orientation "; o; ": SetMode refused transform "; transform$
' SetMode() takes ONE argument — a mode string. Passing a transform as a second argument was a
' "wrong number of function parameters" abort, so this Sub never reached its own reply and the
' page never learned to fall back. Rotation lives on SetScreenModes(), whose per-screen config
' carries a `transform` of normal|90|180|270 and rotates EVERYTHING including the video plane.
' Implemented in BOS 9.0.15+; an older player simply has no method here and is told so.
' FindMemberFunction is the documented way to ask whether a method exists on this OS version —
' safer than naming a member directly, which would attempt the call. It is itself feature-gated
' (see HasFindMember), and a player that cannot ask cannot be told the answer is yes: rotation
' is refused rather than risked, and the page keeps its CSS fallback.
if not HasFindMember() then
print "[st] orientation: cannot probe this OS for SetScreenModes — keeping the CSS fallback"
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "cannot probe this OS version" })
return
end if
widget.PostJSMessage({ type: "orientation-result", ok: ok, transform: transform$ })
if FindMemberFunction(vm, "GetScreenModes") = invalid or FindMemberFunction(vm, "SetScreenModes") = invalid then
print "[st] orientation: this OS has no SetScreenModes — the page keeps its CSS fallback"
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "SetScreenModes unavailable" })
return
end if
configs = vm.GetScreenModes()
if configs = invalid or configs.Count() = 0 then
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "no screen configuration" })
return
end if
' ⚠️ SetScreenModes REBOOTS the player when it changes the screen configuration. A playlist push
' repeats the current orientation on every update, so applying it unconditionally would reboot
' the display every time the server spoke to it. Only a real change is worth a reboot.
changed = false
for each c in configs
if c.transform <> transform$ then
c.transform = transform$
changed = true
end if
end for
if not changed then
print "[st] orientation already "; transform$; " — nothing to do"
widget.PostJSMessage({ type: "orientation-result", ok: true, transform: transform$ })
return
end if
' Tell the page BEFORE the call: the reboot may take the player out mid-sentence, and a display
' that rotates without ever confirming looks like the command was ignored.
widget.PostJSMessage({ type: "orientation-result", ok: true, transform: transform$, rebooting: true })
print "[st] orientation "; o; " -> transform "; transform$; " (the player will now reboot)"
sleep(1000)
vm.SetScreenModes(configs)
End Sub
'=== capability probe =======================================================================
@ -263,35 +406,106 @@ End Sub
' interface is physically dead, and the DWS still refuses the capture — internal flash is not
' "primary storage" as that endpoint means it. Counting it would re-create the exact lie this
' probe exists to prevent.
' Drop a trailing "/" from a drive specifier. roStorageHotplug.GetStorages() answers with one
' ("SSD:/"), the rest of this script speaks the bare form ("SSD:"), and roStorageInfo takes either.
Function TrimDrive(raw As String) As String
n% = Len(raw)
if n% > 0 and Mid(raw, n%, 1) = "/" then return Left(raw, n% - 1)
return raw
End Function
' Turn a drive specifier into the one GetStorageStatus() actually accepts.
'
' GetStorages() -> ["USB1:/", "SD:/", "SD2:/", "SSD:/", "Flash:/"]
' GetStorageStatus() understands "USB:", "SD:", "SSD:", "SD2:/", "Flash:" and is documented as
' UNRELIABLE for "USBn:". So: drop the trailing slash, and collapse any USBn to a bare "USB:".
' roStorageInfo, by contrast, is documented for the NUMBERED form — so the two callers get
' different strings and the numbering is only thrown away where it does harm.
Function StatusDrive(raw As String) As String
d$ = TrimDrive(raw)
if LCase(Left(d$, 3)) = "usb" then return "USB:"
return d$
End Function
Function StorageProbe() As Object
result = { present: false, volume: "", free_mb: 0, total_mb: 0 }
volumes = ["SSD:", "SD:", "USB1:"]
for each v in volumes
' "USB:" not "USB1:" — the docs warn that GetStorageStatus() results are UNRELIABLE when called
' with a "USBn:" parameter, and list "USB:", "SD:", "SSD:", "SD2:/", "Flash:" as the drive
' strings it understands. One roStorageHotplug for the whole loop rather than one per volume.
hp = CreateObject("roStorageHotplug")
' Ask the platform which volumes exist rather than guessing; the static list is the fallback for
' an OS without the enumerator. Same shape BrightSign's own boilerplate uses.
'
' FLASH: is on this list and is NOT on GetStorageStatus()'s. The documented drive strings for
' that method are "SD:", "SSD:" and "USB:" — internal flash is simply not one of the things it
' can answer about, so a player booting from flash (which is how the XT245 in this office ran
' until an NVMe went in) can never be reported as mounted no matter what is actually there.
' Hence the roStorageInfo pass below: the mount check is treated as a hint, not a gate.
volumes = ["SSD:", "SD:", "SD2:", "USB:", "FLASH:"]
' Feature-gated (see HasFindMember). A player that cannot be probed simply keeps the static list,
' which is the answer the enumerator would have given anyway on every model we ship.
if hp <> invalid and HasFindMember() then
if FindMemberFunction(hp, "GetStorages") <> invalid then
found = hp.GetStorages()
if found <> invalid and found.Count() > 0 then volumes = found
end if
end if
for each raw in volumes
' ⚠️ GetStorages() answers in a DIFFERENT vocabulary to the one GetStorageStatus() accepts:
' it returns ["USB1:/", "SD:/", "SD2:/", "SSD:/", "Flash:/"] — trailing slash, and USB
' NUMBERED. GetStorageStatus() is documented as UNRELIABLE when called with a "USBn:"
' parameter and understands "USB:", "SD:", "SSD:", "SD2:/", "Flash:". So handing the
' enumerator's own output straight back to it re-creates exactly the bug the static list was
' written to avoid — silently, and only on the OS versions that HAVE the enumerator, which is
' why the static fallback looked correct in testing.
v = TrimDrive(raw)
mounted = false
hp = CreateObject("roStorageHotplug")
if hp <> invalid then
st = hp.GetStorageStatus(v)
st = hp.GetStorageStatus(StatusDrive(raw))
if st <> invalid and st.mounted then mounted = true
end if
if mounted then
result.present = true
result.volume = v
si = CreateObject("roStorageInfo", v)
if si <> invalid then
' Real device capacity. The widget's storage quota — all the page can see via
' navigator.storage.estimate() — is the cache budget, not the disk.
result.free_mb = si.GetFreeInMegabytes()
result.total_mb = si.GetSizeInMegabytes()
end if
return result
if FillStorage(result, v) then return result
end if
end for
' Nothing claimed to be mounted — which is not the same as nothing being there.
'
' GetStorageStatus() cannot answer for FLASH:, roStorageHotplug may not exist at all on an older
' build, and either way the whole probe hung on one call whose "no" was indistinguishable from
' "cannot say". roStorageInfo is the direct question: a volume that reports a non-zero size IS
' the disk, whatever the hotplug object thinks. The dashboard was showing 1025 MB for a player
' with an NVMe in it — the widget's own cache quota, reported through the fallback in
' st-bridge.js — because this function returned present:false and the page had nothing better.
'
' The mount check still runs FIRST: it is the more meaningful answer where it works, and it
' picks the removable volume ahead of internal flash on a player that has both.
for each raw in volumes
if FillStorage(result, TrimDrive(raw)) then return result
end for
return result
End Function
' Real device capacity for [drive], into [result]. True when the volume answered.
'
' The widget's storage quota — all the page can see via navigator.storage.estimate() — is the cache
' budget, not the disk, which is the entire reason the host is asked at all.
Function FillStorage(result As Object, drive As String) As Boolean
si = CreateObject("roStorageInfo", drive)
if si = invalid then return false
total = si.GetSizeInMegabytes()
if total = invalid or total <= 0 then return false
result.present = true
result.volume = drive
result.total_mb = total
free = si.GetFreeInMegabytes()
if free <> invalid and free >= 0 then result.free_mb = free
return true
End Function
' Everything the page cannot ask the hardware directly.
Sub SendProbeResult(widget As Object)
di = CreateObject("roDeviceInfo")
@ -320,6 +534,34 @@ Function FullScreenRect() As Object
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
End Function
' Where the SECOND output lives inside the combined canvas, or invalid on a single-output player.
'
' GetResX/GetResY only ever describe output 1, so they cannot answer this. The per-screen
' configuration can: each entry carries display_x/display_y (its origin within the canvas built by
' SetScreenModes) and `enabled`. A widget placed at that origin paints that output; there is no
' other mechanism, because roHtmlWidget has no output selector.
'
' Returns invalid unless a SECOND, ENABLED screen genuinely exists — the caller then stays
' single-screen and says so, rather than stacking two widgets on output one. The docs warn that
' GetScreenModes on a player with unconnected outputs "won't get a valid return" for them, so an
' entry that does not describe a real screen is treated as absent.
Function SecondScreenRect() As Object
if not HasFindMember() then return invalid
vm = CreateObject("roVideoMode")
if vm = invalid then return invalid
if FindMemberFunction(vm, "GetScreenModes") = invalid then return invalid
configs = vm.GetScreenModes()
if configs = invalid or configs.Count() < 2 then return invalid
s = configs[1]
if s = invalid then return invalid
if s.enabled <> invalid and s.enabled = false then return invalid
if s.display_x = invalid or s.display_y = invalid then return invalid
return CreateObject("roRectangle", s.display_x, s.display_y, vm.GetResX(), vm.GetResY())
End Function
'=== self-update ============================================================================
'
' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the
@ -349,9 +591,34 @@ Function PackageVersion() As String
return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand)
End Function
Function DoesFileExist(filePath$ As String) As Boolean
files = MatchFiles(filePath$, filePath$)
return files.Count() > 0
' Can this player use FindMemberFunction() at all?
'
' ⚠️ It is NOT unconditionally available: "It is only available if
' roDeviceInfo.HasFeature("FindMemberFunction") returns true." Calling it on a player without the
' feature is a runtime error — and both call sites are reached FROM THE EVENT LOOP (the capability
' probe on every boot, the storage figures in host telemetry every 60 seconds), so on such a player
' the host script would die within a minute of starting and take the display with it. The guard it
' was being used AS is the thing that needed guarding.
Function HasFindMember() As Boolean
di = CreateObject("roDeviceInfo")
if di = invalid then return false
return di.HasFeature("FindMemberFunction")
End Function
' Does [path] exist?
'
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
' published autozip.brs). It takes a FULL PATH, which is what every call site naturally has.
'
' MatchFiles is deliberately not used here. It is for LISTING a directory: it takes a directory plus
' a pattern, returns nothing when the pattern contains a separator, and — as this player
' demonstrated — does not reliably answer for a volume root like "SSD:/". The first version of this
' function passed a path as both arguments and could never return true at all; the second passed a
' directory and a bare name and still answered "no" for a file sitting right there. An existence
' check that is subtly wrong is worse than none, because every guard built on it silently opens.
Function FileExists(path As String) As Boolean
f = CreateObject("roReadFile", path)
return type(f) = "roReadFile"
End Function
' Unpack a package that is sitting on storage waiting to be applied. Runs BEFORE the widget so a
@ -361,40 +628,62 @@ End Function
' card holds nothing but autorun.zip and the OS processes it. Once autorun.brs exists at the
' storage root the OS no longer auto-processes the archive — so from then on the host has to do it
' itself, or self-update would work exactly once.
Sub ApplyPendingPackage(root As String)
Sub ApplyPendingPackage(root As String, buf As Object)
dir$ = root + "/"
zipPath$ = root + "/autorun.zip"
donePath$ = root + "/autorun.zip.done"
badPath$ = root + "/autorun.zip.bad"
stage$ = root + "/st-staging"
if not DoesFileExist(zipPath$) then return
if DoesFileExist(donePath$) then return ' already unpacked; extracting again is the boot loop
if not FileExists(dir$ + "autorun.zip") then return
if FileExists(dir$ + "autorun.zip.done") then return ' already unpacked; again is the boot loop
print "[st-update] unpacking pending package"
LogTo(buf, "update", "unpacking pending package")
package = CreateObject("roBrightPackage", zipPath$)
if package = invalid then
print "[st-update] ERROR: archive unreadable (is it STORED?) — parking it as .bad"
fs = CreateObject("roFileSystem")
if fs <> invalid then fs.Rename(zipPath$, badPath$)
LogTo(buf, "update", "ERROR: archive unreadable — parking it as .bad")
MoveFile(zipPath$, badPath$)
return
end if
if not package.Unpack(root + "/") then
print "[st-update] ERROR: extract failed — parking it as .bad so we do not retry forever"
fs = CreateObject("roFileSystem")
if fs <> invalid then fs.Rename(zipPath$, badPath$)
' ⚠️ Unpack() DELETES everything already in its target directory: "Providing a destination path
' of SD:/ will wipe all preexisting files from the card". Unpacking straight to the volume root
' would therefore erase this player's provisioning and its entire content pool on every update —
' the update would work and the display would come back empty and unpaired.
'
' So it goes to a staging directory of its own, and the files are moved into place afterwards.
' The wipe is then a FEATURE: it clears any half-extracted remains of a previous attempt.
CreateDirectory(stage$)
package.Unpack(stage$ + "/")
' Unpack() returns Void, so success is proven by looking for what should now exist rather than
' by testing a return value that was never there.
if not FileExists(stage$ + "/autorun.brs") then
LogTo(buf, "update", "ERROR: extract produced no autorun.brs — parking it as .bad")
MoveFile(zipPath$, badPath$)
return
end if
fs = CreateObject("roFileSystem")
if fs = invalid then return
if not fs.Rename(zipPath$, donePath$) then
' screentinker.json is deliberately NOT copied over: it carries THIS player's provisioning
' (server URL, device id), and the copy inside a package carries the build's defaults. Letting
' an update overwrite it would re-point or unpair the display as a side effect of a routine
' upgrade — silently, and on every player at once.
moved% = 0
for each name in MatchFiles(stage$, "*")
if name <> "screentinker.json" then
if MoveFile(stage$ + "/" + name, root + "/" + name) then moved% = moved% + 1
end if
end for
LogTo(buf, "update", "installed " + Stri(moved%).Trim() + " file(s)")
if not MoveFile(zipPath$, donePath$) then
' Refusing to reboot without the marker: we would extract and reboot forever.
print "[st-update] ERROR: could not mark done — not rebooting"
LogTo(buf, "update", "ERROR: could not mark done — not rebooting")
return
end if
print "[st-update] package applied — rebooting into it"
LogTo(buf, "update", "package applied — rebooting into it")
sleep(2000)
RebootSystem()
End Sub
@ -405,6 +694,15 @@ End Sub
Sub CheckPackageUpdate(cfg As Object, root As String)
if cfg.server_url = "" then return
' A package already staged and waiting for its apply-reboot is not a reason to fetch another.
' Observed on hardware: the periodic check fired in the gap between staging and rebooting and
' pulled the whole archive down a second time. Harmless here; on a metered or marginal link it
' is the same waste this product spent a release eliminating everywhere else.
if FileExists(root + "/autorun.zip") then
print "[st-update] a package is already staged — waiting for it to apply"
return
end if
partPath$ = root + "/autorun.zip.part"
reg = CreateObject("roRegistrySection", "screentinker")
attempts% = 0
@ -419,7 +717,7 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
xfer.SetUrl(url$)
xfer.EnablePeerVerification(true)
body$ = xfer.GetToString()
if body$ = "" then return ' unreachable: keep running what works
if body$ = "" then return ' unreachable server: keep running what works
manifest = ParseJson(body$)
if manifest = invalid then return
@ -429,12 +727,22 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
return
end if
' Guard the manifest HERE, at the call site, because that is the only place a guard can help.
' VerifyPackage takes `As String` / `As Integer` parameters, and a missing key is `invalid`:
' handing invalid to a typed parameter is a runtime error raised at the CALL, before a single
' line inside the function runs. The check inside VerifyPackage reads like it covers this and
' cannot — the script would already have aborted, from inside the event loop, taking playback
' down with it. url is checked for the same reason (it is concatenated into a `As String`).
if manifest.url = invalid or manifest.sha256 = invalid or manifest.size = invalid then
print "[st-update] manifest says download but is missing url/sha256/size — ignoring it"
return
end if
print "[st-update] downloading package "; manifest.version
' Any earlier partial is deleted first: resuming into an existing file would concatenate two
' downloads into something that hashes to neither.
fs = CreateObject("roFileSystem")
if fs <> invalid and DoesFileExist(partPath$) then fs.Delete(partPath$)
if FileExists(root + "/autorun.zip.part") then DeleteFile(partPath$)
dl = CreateObject("roUrlTransfer")
if dl = invalid then return
@ -443,7 +751,7 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
if dl.GetToFile(partPath$) <> 200 then
print "[st-update] download failed"
RecordPackageAttempt(reg, attempts% + 1)
if fs <> invalid then fs.Delete(partPath$)
DeleteFile(partPath$)
return
end if
@ -451,15 +759,14 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
if not VerifyPackage(partPath$, manifest.sha256, manifest.size) then
print "[st-update] VERIFICATION FAILED — discarding, staying on "; PackageVersion()
RecordPackageAttempt(reg, attempts% + 1)
if fs <> invalid then fs.Delete(partPath$)
DeleteFile(partPath$)
return
end if
' Promote. Marker first — see the ordering note above.
if fs = invalid then return
if DoesFileExist(root + "/autorun.zip.done") then fs.Delete(root + "/autorun.zip.done")
if DoesFileExist(root + "/autorun.zip") then fs.Delete(root + "/autorun.zip")
if not fs.Rename(partPath$, root + "/autorun.zip") then
if FileExists(root + "/autorun.zip.done") then DeleteFile(root + "/autorun.zip.done")
if FileExists(root + "/autorun.zip") then DeleteFile(root + "/autorun.zip")
if not MoveFile(partPath$, root + "/autorun.zip") then
print "[st-update] ERROR: could not stage the package — staying put"
RecordPackageAttempt(reg, attempts% + 1)
return
@ -482,41 +789,177 @@ End Sub
' sha256 + size. Both matter: the hash proves the bytes are the ones we were promised, the size
' floor catches an error page or captive-portal login saved under the package's name.
Function VerifyPackage(path As String, expected As String, expectedSize As Integer) As Boolean
if expected = invalid or expected = "" then return false
' Guard the arguments before the type declarations do it for us: a manifest missing sha256 or
' size passes `invalid` into an `As String`/`As Integer` parameter, which is a runtime error at
' the CALL — before any check inside the function could help.
if expected = "" then return false
fs = CreateObject("roFileSystem")
if fs = invalid then return false
info = fs.Stat(path)
if info = invalid then return false
if info.size < 1024 then
print "[st-update] package is implausibly small ("; info.size; " bytes)"
return false
end if
if expectedSize > 0 and info.size <> expectedSize then
print "[st-update] size mismatch: got "; info.size; " expected "; expectedSize
' roByteArray + roHashGenerator. The previous version used roFileSystem.Stat/OpenInputFile and
' roMessageDigest — all three are Roku objects that do not exist on BrightSign, so this function
' returned false unconditionally and every self-update failed verification and burned an
' attempt. The package is tens of kilobytes, so reading it whole is cheaper than the streaming
' loop it replaces.
ba = CreateObject("roByteArray")
if ba = invalid then return false
if not ba.ReadFile(path) then
print "[st-update] package unreadable at "; path
return false
end if
digest = CreateObject("roMessageDigest")
size% = ba.Count()
if size% < 1024 then
print "[st-update] package is implausibly small ("; size%; " bytes)"
return false
end if
if expectedSize > 0 and size% <> expectedSize then
print "[st-update] size mismatch: got "; size%; " expected "; expectedSize
return false
end if
hg = CreateObject("roHashGenerator", "sha256")
if hg = invalid then return false
digest = hg.Hash(ba)
if digest = invalid then return false
digest.SetAlgorithm("sha256")
file = fs.OpenInputFile(path)
if file = invalid then return false
while true
chunk = file.Read(65536)
if chunk.Count() = 0 then exit while
digest.Update(chunk)
end while
return LCase(digest.Final()) = LCase(expected)
' Hash() answers with an roByteArray, not a string.
return LCase(digest.ToHexString()) = LCase(expected)
End Function
'=== host diagnostics =======================================================================
'
' Everything the host knows that the PAGE cannot ask for, routed into the same channels the other
' players already use: the dashboard log stream, the device-event feed, and the heartbeat telemetry.
'
' This exists because of a specific, expensive afternoon. A single bad string literal stopped this
' script compiling, and the only evidence anywhere was a line on a serial console — the server saw a
' player that simply never appeared, and the display showed nothing. Every other player reports its
' own failures; this one printed them to a cable. A panel on a wall has no cable.
'
' The pre-widget phase is the part that matters most and is the part that is hardest to reach: the
' storage probe, a pending package being applied, the video mode being set, all happen before there
' is a page to talk to. Those lines accumulate in a buffer and are flushed the moment the widget
' exists, so the boot story arrives even though it happened before anyone could listen.
' Append a diagnostic to the pre-widget buffer AND put it on the console. The buffer is an roArray
' created in Main and passed down; BrightScript has no global store (no GetGlobalAA here), and
' threading it explicitly beats the alternative of losing the boot entirely.
Sub LogTo(buf As Object, tag As String, message As String)
print "[st-"; tag; "] "; message
if buf <> invalid then
if buf.Count() < 200 then ' a boot that logs 200 lines has a worse problem
buf.Push({ tag: tag, message: message })
end if
end if
End Sub
' Send one diagnostic to the page, which forwards it to the server as a device:log line.
Sub HostLog(widget As Object, tag As String, message As String)
print "[st-"; tag; "] "; message
if widget = invalid then return
widget.PostJSMessage({ type: "host-log", tag: tag, level: "i", message: message })
End Sub
' Hand the buffered boot diagnostics to the page in one go, oldest first.
Sub FlushLog(widget As Object, buf As Object)
if widget = invalid or buf = invalid then return
for each line in buf
widget.PostJSMessage({ type: "host-log", tag: line.tag, level: "i", message: line.message })
end for
buf.Clear()
End Sub
' A device EVENT rather than a log line: these land in the incident feed the dashboard shows against
' a display, so they are reserved for things an operator would want explained — a reboot, a network
' change, the player falling over.
Sub HostEvent(widget As Object, event As String, reason As String, detail As String)
print "[st-event] "; event; " "; reason; " "; detail
if widget = invalid then return
widget.PostJSMessage({ type: "host-event", event: event, reason: reason, detail: detail })
End Sub
' The facts only the host can see. The page has no API for any of this: @brightsign/storage exposes
' format and eject, not volumes; there is no JavaScript route to the uptime, the wired IP, the video
' mode actually in force, or which volume the player booted from.
Sub SendHostTelemetry(widget As Object, cfg As Object)
if widget = invalid then return
t = { type: "host-telemetry" }
' Seconds since boot. A display that reports a small uptime every time it is polled is
' rebooting in a loop, which is otherwise indistinguishable from a healthy one.
up = UpTime(0)
if up <> invalid then t.uptime_seconds = Int(up)
di = CreateObject("roDeviceInfo")
if di <> invalid then
t.model = di.GetModel()
t.os_version = di.GetVersion()
end if
' The address this player holds on the LAN — the one an integrator needs to reach its DWS on
' site, and the one the dashboard has never been able to show for a BrightSign.
'
' Interface 0 alone was not enough. Our XT245 produced 6000 telemetry rows with local_ip NULL
' while sitting on a healthy PoE network with a perfectly good address, and every other field in
' this same payload arrived. So try each interface the platform documents rather than assuming
' the first one answers: 0/"eth0" is the Ethernet port, "eth1" the control port on players that
' have one, 1/"wlan0" the internal WiFi.
'
' ⚠️ The STRING forms matter. Per the Object Reference, an INTEGER interface "must currently
' exist on the player; otherwise the object-creation function will return Invalid" — the string
' names carry no such condition, so they are the ones that answer when the integer does not.
'
' NOT roDeviceInfo.GetIPAddrs(): that is Roku's API. BrightSign's roDeviceInfo has no network
' method at all, and calling it would raise "Member function not found" here every minute. This
' is the exact family of mistake server/test/brightscript-api-surface.test.js exists to catch.
' The list is mixed integer/string on purpose (see above) and is walked in full rather than with
' an early exit, because the guard is the same either way and a typed `exit for` inside a nested
' if is exactly the sort of thing that cannot be checked from this repo.
for each iface in [0, "eth0", "eth1", 1, "wlan0"]
if t.local_ip = invalid then
nc = CreateObject("roNetworkConfiguration", iface)
if nc <> invalid then
cur = nc.GetCurrentConfig()
if cur <> invalid and cur.ip4_address <> invalid and cur.ip4_address <> "" then
t.local_ip = cur.ip4_address
end if
end if
end if
end for
' Say so when nothing answered. Silence here is what made this invisible for a whole fleet: the
' field simply stayed NULL and looked like a server-side gap rather than a player that never
' sent it. One line on the host log costs nothing and names the real state.
if t.local_ip = invalid then
HostLog(widget, "net", "no ip4_address on any interface (0/eth0/eth1/1/wlan0)")
end if
vm = CreateObject("roVideoMode")
if vm <> invalid then t.video_mode = vm.GetMode()
' Which volume is actually in use, and how much of it is left. The page's
' navigator.storage.estimate() reports the widget's CACHE QUOTA, not the disk — a panel can
' report gigabytes free while the volume holding them is full.
st = StorageProbe()
if st.present then
t.storage_volume = st.volume
t.storage_free_mb = st.free_mb
t.storage_total_mb = st.total_mb
end if
t.boot_volume = StorageRoot()
t.package_version = PackageVersion()
widget.PostJSMessage(t)
End Sub
'=== main ===================================================================================
Sub Main()
' Diagnostics from before there is a page to send them to. Flushed the moment the widget exists.
boot = CreateObject("roArray", 32, true)
cfg = LoadConfig()
LogTo(boot, "boot", "host " + PackageVersion() + " from " + StorageRoot() + " -> " + cfg.server_url)
' Crash dumps land here if the widget ever falls over — cheap, and the only forensic trail
' available on a panel nobody can reach.
@ -528,7 +971,7 @@ Sub Main()
' A package staged by a previous run lands here, before anything is on screen. Doing it after
' the widget started would mean rebooting out of a playing playlist, and the panel would blink
' mid-content for a reason nobody watching could explain.
ApplyPendingPackage(StorageRoot())
ApplyPendingPackage(StorageRoot(), boot)
port = CreateObject("roMessagePort")
@ -548,12 +991,37 @@ Sub Main()
widget = MakeWidget(PlayerUrl(cfg, 1), rect, port, cfg)
widget.Show()
' NOT flushed here. Show() only creates the widget — the page has not been fetched, let alone
' run st-bridge.js, so there is nothing on the other end of PostJSMessage yet and every line
' would go into the void. Buffered instead until the page says hello (its `probe` message,
' which st-bridge.js posts as soon as it loads), which is the whole reason the buffer exists.
' The same window ate SendHostTelemetry; telemetry repeats every 60s so it self-healed and the
' boot report — the one that only ever happens once — did not.
widget2 = invalid
if dual then
screen2 = 2
if cfg.output_mode = "clone" then screen2 = 1
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect, port, cfg)
if widget2 <> invalid then widget2.Show()
' ⚠️ There is NO per-widget output selector. roHtmlWidget takes a rectangle and nothing else:
' its init parameters have no `screen`/`output` key, and neither does the JavaScript
' HtmlWidgetParams. A second output is addressed by BUILDING ONE TALL CANVAS with
' SetScreenModes (display_x/display_y stack the outputs) and then placing the second widget
' at that offset inside it.
'
' Which means the previous version could not work: it passed the SAME full-screen rect for
' both widgets, so widget 2 was composited directly on top of widget 1 on output ONE — two
' players fighting over one screen while the second output stayed dark. "dual" and "clone"
' were configuration options that made the display worse and reported nothing.
rect2 = SecondScreenRect()
if rect2 = invalid then
' Refused rather than guessed. Multi-output is documented for the XC2055 (two) and
' XC4055 (four); the XT line has HDMI IN and HDMI OUT, which the series blurb describes
' as "dual HDMI" and which is not a second output at all.
LogTo(boot, "boot", "output_mode=" + cfg.output_mode + " but this player exposes one output — staying single-screen")
HostEvent(widget, "app_error", "output-mode", "dual/clone requested; this player has a single output")
else
screen2 = 2
if cfg.output_mode = "clone" then screen2 = 1
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect2, port, cfg)
if widget2 <> invalid then widget2.Show()
end if
end if
retries = 0
@ -573,7 +1041,14 @@ Sub Main()
' exception, decoder stall) without the OS ever reporting an error. st-bridge.js posts a
' heartbeat every 30s; three missed beats and we rebuild the widget. This is the difference
' between a panel that recovers on its own and one that needs a site visit.
WATCHDOG_MS = 120000
' Seconds first, milliseconds derived: the diagnostic message needs an INTEGER to format, and
' dividing at the call site would hand Stri a float.
WATCHDOG_S = 120
WATCHDOG_MS = WATCHDOG_S * 1000
lastHostTel = CreateObject("roTimespan")
lastHostTel.Mark()
HOST_TEL_MS = 60000
while true
msg = wait(5000, port)
@ -590,7 +1065,15 @@ Sub Main()
' Back off, then fall back to the local page so the screen says something
' truthful instead of showing white. The local page keeps retrying the server.
retries = retries + 1
print "[st] load-error ("; retries; "): "; data.url
' The key is `uri` on a load-error; `url` belongs to download-request. Printing
' the wrong one meant the single diagnostic that names the failing resource always
' printed "invalid".
' data.uri is already a String per the event contract, so no conversion is wanted:
' Str() is for numbers and would abort the event loop. Guarded because a missing key
' yields invalid, and assigning invalid to a $-typed name is a runtime error.
uri$ = ""
if data.uri <> invalid then uri$ = data.uri
HostEvent(widget, "app_error", "load-error", "attempt " + Stri(retries).Trim() + ": " + uri$)
sleep(ChooseBackoff(retries))
if retries >= 3 then
' The server URL rides along so the fallback page can name it on screen and
@ -639,6 +1122,11 @@ Sub Main()
' Asked once during boot, before the player registers: the answer decides which
' controls the dashboard is allowed to offer for this display.
SendProbeResult(widget)
' ...and this is the first PROOF that a page is listening, so it is the earliest
' moment the buffered boot story can actually be delivered. st-bridge.js holds it
' until the player's socket is up, so late here is still in time.
FlushLog(widget, boot)
SendHostTelemetry(widget, cfg)
else if m.type = "set-orientation" then
if m.orientation <> invalid then SetOrientation(widget, m.orientation)
@ -668,11 +1156,21 @@ Sub Main()
' watchdog
if lastBeat.TotalMilliseconds() > WATCHDOG_MS then
print "[st] watchdog: no heartbeat in "; WATCHDOG_MS; "ms — rebuilding widget"
' Reported as a crash, because that is what it is from the floor: the page stopped
' answering and the host restarted it. Previously this healed the panel in silence, so a
' display rebuilding itself every two minutes looked identical to one that was fine.
HostEvent(widget, "crash", "watchdog", "no heartbeat for " + Stri(WATCHDOG_S).Trim() + "s — rebuilt the widget")
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
lastBeat.Mark()
end if
' Host facts, on the same cadence as the package check is cheap but far too slow to be
' useful; every telemetry tick would be too chatty. A minute is what the dashboard shows.
if lastHostTel.TotalMilliseconds() > HOST_TEL_MS then
lastHostTel.Mark()
SendHostTelemetry(widget, cfg)
end if
' Periodic package check. Marked BEFORE the call, not after: a check that blocks on a slow
' server would otherwise be retried immediately on the next tick and hammer it.
if cfg.self_update and lastPkgCheck.TotalMilliseconds() > PKG_CHECK_MS then

View file

@ -13,11 +13,13 @@
' being processed at all. autorun.brs belongs INSIDE the zip, which is where the build script puts
' it (scripts/build-autorun-zip.sh).
'
' Unpacks with roBrightPackage, which is what BrightSign's own tooling uses — NOT roUnzip.
' A BrightSign consultant flagged this after our first archive failed his automated deployment:
' the zip reached the player and then could not be opened. Two causes, both fixed:
' - the archive must be STORED, no compression (scripts/build-autorun-zip.sh now asserts it)
' - roBrightPackage is the supported reader for a player package
' Unpacks with roBrightPackage, which is what BrightSign's own tooling uses.
'
' On compression: roBrightPackage supports deflate32 with default options, PPMd, and "no
' compression". What it does NOT support is bzip2, LZMA, Deflate64 and Zip64 (the last being what
' Windows Explorer's built-in zipper produces). We build STORED, which is stricter than required and
' costs nothing at this size — but note that compression was NOT the cause of the deployment failure
' this script was blamed for. That was the MatchFiles bug below.
'
' Requires BrightSignOS 7.0.60+.
@ -26,9 +28,9 @@
' for the file beats assuming a volume: extracting to "SD:/" on a player with no card writes to a
' volume that does not exist, and the deployment silently does nothing.
Function SourceRoot() As String
volumes = ["USB1:", "SD:", "SSD:", "FLASH:"]
volumes = ["USB1:", "SD:", "SD2:", "SSD:", "FLASH:"]
for each v in volumes
if DoesFileExist(v + "/autorun.zip") then return v
if FileExists(v + "/autorun.zip") then return v
end for
return ""
End Function
@ -45,14 +47,14 @@ Sub Main()
print "[st-autozip] volume "; root$
if not DoesFileExist(zipPath$) then
if not FileExists(extractPath$ + "autorun.zip") then
print "[st-autozip] no autorun.zip at "; zipPath$; " — nothing to do"
return
end if
' Idempotence. Without this the player extracts, reboots, extracts again, reboots again —
' a boot loop that looks like a hardware fault.
if DoesFileExist(donePath$) then
if FileExists(extractPath$ + "autorun.zip.done") then
print "[st-autozip] already unpacked (autorun.zip.done present) — leaving it alone"
return
end if
@ -67,20 +69,22 @@ Sub Main()
return
end if
if not package.Unpack(extractPath$) then
print "[st-autozip] ERROR: unpack failed"
' Unpack() returns VOID — there is no boolean to test, and `if not package.Unpack(...)` was a
' type error rather than an error check. Success is proven the only way that actually means
' anything: the file we came here to install is now on the card.
package.Unpack(extractPath$)
if not FileExists(extractPath$ + "autorun.brs") then
print "[st-autozip] ERROR: unpack produced no autorun.brs — leaving the archive for a retry"
return
end if
print "[st-autozip] extracted"
fs = CreateObject("roFileSystem")
if fs = invalid then
print "[st-autozip] ERROR: no roFileSystem — cannot mark the archive done"
return
end if
if not fs.Rename(zipPath$, donePath$) then
' MoveFile/DeleteFile are GLOBAL functions on BrightSign. roFileSystem is a Roku object and does
' not exist here, so every one of these calls used to return invalid — which meant the archive
' was never marked done and the player never rebooted into the player it had just installed.
if not MoveFile(zipPath$, donePath$) then
print "[st-autozip] ERROR: could not rename the archive; refusing to reboot into a loop"
return
end if
@ -90,7 +94,18 @@ Sub Main()
RebootSystem()
End Sub
Function DoesFileExist(filePath$ As String) As Boolean
files = MatchFiles(filePath$, filePath$)
return files.Count() > 0
' Does [path] exist?
'
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
' published autozip.brs). It takes a FULL PATH, which is what every call site naturally has.
'
' MatchFiles is deliberately not used here. It is for LISTING a directory: it takes a directory plus
' a pattern, returns nothing when the pattern contains a separator, and — as this player
' demonstrated — does not reliably answer for a volume root like "SSD:/". The first version of this
' function passed a path as both arguments and could never return true at all; the second passed a
' directory and a bare name and still answered "no" for a file sitting right there. An existence
' check that is subtly wrong is worse than none, because every guard built on it silently opens.
Function FileExists(path As String) As Boolean
f = CreateObject("roReadFile", path)
return type(f) = "roReadFile"
End Function

View file

@ -35,8 +35,28 @@
var MessagePortClass = tryRequire('@brightsign/messageport');
var RegistryClass = tryRequire('@brightsign/registry');
var DeviceInfoClass = tryRequire('@brightsign/deviceinfo');
var VideoOutputClass = tryRequire('@brightsign/videooutput');
/*
* @brightsign/videooutput does NOT set a video mode. Its surface is read-only plus power
* (getVideoResolution / getEdid / isAttached / setPowerSaveMode / setBackgroundColor); there is
* no setMode on it at all. Mode setting lives on @brightsign/videomodeconfiguration, whose
* setMode() returns a Promise<{restartRequired}>.
*
* The two were conflated here, and the cost was not a broken call the call was guarded it was
* a LIE: a widget with no host bridge declared display.resolution purely because videooutput
* resolved, and the dashboard grew a resolution control that could never do anything.
*/
var VideoModeConfigClass = tryRequire('@brightsign/videomodeconfiguration');
var CecClass = tryRequire('@brightsign/cec');
// Reads the attached display's EDID. Read-only; the mode setter is videomodeconfiguration.
var VideoOutputClass = tryRequire('@brightsign/videooutput');
/*
* Node's standard library, present because the widget is created with nodejs_enabled. Used for
* the LAN address (see refreshTelemetry) exactly as BrightSign's own dev-cookbook templates do.
* tryRequire, not a bare require: in a plain browser there is no require at all, and this file
* must load there too.
*/
var osModule = tryRequire('os');
var fsModule = tryRequire('fs');
var port = null;
if (MessagePortClass) {
@ -230,6 +250,89 @@
// its own telemetry object, and a null here would overwrite a value another player family had
// legitimately supplied. Absent means "nothing to say", which is not the same as "zero".
var telemetry = {};
/*
* Facts pushed by the host, merged into the same cache the heartbeat reads.
*
* Registered at load, directly on the listener list rather than behind the readiness gate: the
* host starts sending these the moment the widget exists, and anything attached later would miss
* the boot report the one that says which volume the player came up from and whether a package
* applied.
*
* The host's numbers WIN over the page's where they overlap. navigator.storage.estimate()
* describes the widget's cache quota, not the disk: a panel can report gigabytes free while the
* volume holding them is full, and only the host can tell the difference.
*/
/*
* Host diagnostics arrive BEFORE anyone is listening, and that is not an edge case it is the
* normal order of events and the whole reason they are worth carrying.
*
* The host buffers its pre-widget boot lines and posts them the moment the page says hello. The
* player, correctly, does not subscribe until its socket is connected, because a line forwarded
* before that has nowhere to go. Between those two facts every boot line was dropped: the host
* spoke into a page with no listener, and the listener arrived after the words had gone. The
* player's own comment says wiring earlier "would drop the host's boot report on the floor"
* which was true, and left the report on the floor anyway.
*
* So the bridge holds them. Messages land in these queues from the moment the file loads, and are
* replayed to each consumer as it registers. Bounded, because a host stuck in a reboot loop must
* not grow this without limit on a player that runs for months.
*/
var PENDING_MAX = 200;
var logSinks = [];
var eventSinks = [];
var pendingLogs = [];
var pendingEvents = [];
function drain(queue, fn) {
// Copied first: fn is free to register another sink, and iterating a live array while it is
// being appended to is how a replay turns into a loop.
var items = queue.slice();
for (var i = 0; i < items.length; i++) {
try { fn(items[i]); } catch (e) { /* one bad consumer must not eat the rest of the boot log */ }
}
}
function fanout(sinks, queue, payload) {
if (sinks.length === 0) {
if (queue.length < PENDING_MAX) queue.push(payload);
return;
}
for (var i = 0; i < sinks.length; i++) {
try { sinks[i](payload); } catch (e) { /* ignore */ }
}
}
listeners.push(function (msg) {
if (!msg) return;
if (msg.type === 'host-log') {
fanout(logSinks, pendingLogs, {
tag: String(msg.tag || 'host').slice(0, 64),
level: String(msg.level || 'i').slice(0, 8),
message: String(msg.message || '').slice(0, 2000)
});
return;
}
if (msg.type === 'host-event' && msg.event) {
fanout(eventSinks, pendingEvents, {
event: String(msg.event),
reason: String(msg.reason || '').slice(0, 64),
detail: String(msg.detail || '').slice(0, 500)
});
}
});
listeners.push(function (msg) {
if (msg && msg.type === 'host-telemetry') {
var keys = ['uptime_seconds', 'local_ip', 'model', 'os_version', 'video_mode',
'storage_volume', 'storage_free_mb', 'storage_total_mb',
'boot_volume', 'package_version'];
for (var i = 0; i < keys.length; i++) {
var v = msg[keys[i]];
if (v !== undefined && v !== null && v !== '') telemetry[keys[i]] = v;
}
}
});
var TELEMETRY_REFRESH_MS = 60000;
var deviceInfo = null;
@ -303,10 +406,21 @@
*/
add('playback.transitions'); add('playback.pip');
// Service-worker content caching. The quota is configured in autorun.brs (storage_path +
// storage_quota); without a service worker there is no offline story at all.
/*
* Service-worker content caching and this platform does not have it.
*
* `navigator.serviceWorker` EXISTS on a BrightSign widget and is not usable: our XT245 on alpha
* passes this exact check, then never even fetches sw.js. Presence was therefore the one signal
* that could not distinguish "caches offline" from "cannot", and it answered yes to both the
* player advertised offline.cache to the whole fleet while being unable to hold a single byte
* through an outage. The web player already learned this (it waits for a worker that is in
* CONTROL, see declareCapabilities in server/player/index.html); this copy had not.
*
* A controller is proof, not a promise: something is actually intercepting this page's fetches.
*/
try {
if (global.navigator && global.navigator.serviceWorker) add('offline.cache');
var sw = global.navigator && global.navigator.serviceWorker;
if (sw && sw.controller) add('offline.cache');
} catch (e) { /* no SW in this widget */ }
// ---- needs the host bridge --------------------------------------------------------------
@ -318,8 +432,8 @@
add('system.reboot'); // RebootSystem
add('display.rotation'); // roVideoMode transform — the ONLY way video rotates here
add('display.resolution'); // roVideoMode SetMode
} else if (VideoOutputClass) {
// No host, but the JS video-output module resolved: resolution alone is still reachable.
} else if (VideoModeConfigClass) {
// No host, but the JS mode-configuration module resolved: resolution alone is still reachable.
add('display.resolution');
}
@ -396,7 +510,12 @@
serial: function () {
if (deviceInfo) {
try {
var s = deviceInfo.serialNumber || (deviceInfo.getDeviceUniqueId && deviceInfo.getDeviceUniqueId());
// `serialNumber` is the whole answer. There is no getDeviceUniqueId() on
// @brightsign/deviceinfo — that is the BrightScript roDeviceInfo method name, and
// BrightSign's own migration note maps it to this attribute. `deviceUniqueId` is the
// legacy BSDeviceInfo global's spelling, also an attribute rather than a call, and is
// read here only so a very old widget build still answers with something.
var s = deviceInfo.serialNumber || deviceInfo.deviceUniqueId;
if (s) return String(s);
} catch (e) { /* fall through to the URL */ }
}
@ -532,10 +651,17 @@
},
setVideoMode: function (mode) {
if (VideoOutputClass) {
if (VideoModeConfigClass) {
try {
var vo = new VideoOutputClass();
if (vo && typeof vo.setMode === 'function') { vo.setMode(mode); return true; }
var vmc = new VideoModeConfigClass();
if (vmc && typeof vmc.setMode === 'function') {
// Promise<{restartRequired}>. Nothing here awaits it — a mode change that restarts the
// application takes this page with it, so there is no "after" to report into. Rejection
// is swallowed rather than left as an unhandled rejection on a signage player.
var r = vmc.setMode(mode);
if (r && typeof r.catch === 'function') r.catch(function () {});
return true;
}
} catch (e) { /* fall back to the host */ }
}
return post({ type: 'set-video-mode', mode: mode });
@ -553,6 +679,89 @@
* DWS writes the full capture to disk before returning a thumbnail), the caller gets a reason
* it can show instead of a spinner that never resolves.
*/
/*
* Capture the screen using BrightSign's OWN screenshot API the composite of the video and
* graphics layers, which is the whole point: an in-page canvas cannot read the hardware video
* plane, so a DOM composite returns a frame with the content missing.
*
* Entirely page-side, and that is what makes it work here. The obvious route was to ask the
* host (BrightScript) to capture via the player's DWS, but page->host messaging is dead after
* load on this platform, so the request never arrived. `@brightsign/screenshot` needs no host,
* no DWS, no messageport just the Node `require` the widget already has (the same one that
* makes `module` visible to classic scripts).
*
* The API writes a FILE rather than returning bytes, so it is read straight back with Node's
* fs available for exactly the same reason require() is.
*/
captureScreen: function (opts) {
var o = opts || {};
return new Promise(function (resolve, reject) {
var ScreenshotClass = tryRequire('@brightsign/screenshot');
var fs = tryRequire('fs');
if (!ScreenshotClass) { reject(new Error('no @brightsign/screenshot module')); return; }
if (!fs) { reject(new Error('no fs module')); return; }
// RAM FIRST, deliberately. The remote-control view drives this once a second, and a
// screenshot per second written to the boot flash is a wear-out mechanism with no upside —
// the file is read back and deleted microseconds later, so it never needs to be durable.
// BrightSign exposes tmp as a RAM volume alongside the storage ones. Real storage is only
// a fallback for a unit that does not present tmp, and the directory must already exist or
// the capture fails, so each candidate is checked rather than assumed.
var dirs = ['/storage/tmp', '/tmp', '/storage/ssd', '/storage/usb1', '/storage/sd', '/storage/flash'];
var dir = null;
for (var i = 0; i < dirs.length; i++) {
try { if (fs.existsSync(dirs[i])) { dir = dirs[i]; break; } } catch (e) { /* keep looking */ }
}
if (!dir) { reject(new Error('no writable volume for the capture')); return; }
var path = dir + '/st-capture.jpg';
try { fs.unlinkSync(path); } catch (e) { /* first run, or already gone */ }
var params = {
destinationFileName: path,
fileName: path, // deprecated alias, still honoured on older firmware
fileType: 'JPEG',
width: o.width || 960,
height: o.height || 540,
quality: o.quality || 70,
rotation: 0,
};
var shot;
try { shot = new ScreenshotClass(); } catch (e) { reject(new Error('screenshot object: ' + e.message)); return; }
try {
// syncCapture may interrupt on-screen operations, which the docs flag as a debugging
// trait — but it guarantees the file exists when it returns, and an operator asking for
// one screenshot is worth a single frame of interruption. The stream path uses async.
if (o.async && typeof shot.asyncCapture === 'function') shot.asyncCapture(params);
else if (typeof shot.syncCapture === 'function') shot.syncCapture(params);
else if (typeof shot.asyncCapture === 'function') shot.asyncCapture(params);
else { reject(new Error('screenshot object exposes neither capture method')); return; }
} catch (e) { reject(new Error('capture failed: ' + e.message)); return; }
// Poll for the file rather than trusting a return value: sync and async differ, and the
// documented contract is "a file appears", not "a promise settles".
var waited = 0;
var tick = function () {
var st = null;
try { st = fs.statSync(path); } catch (e) { st = null; }
if (st && st.size > 512) {
var b64;
try { b64 = fs.readFileSync(path).toString('base64'); }
catch (e) { reject(new Error('could not read the capture: ' + e.message)); return; }
try { fs.unlinkSync(path); } catch (e) { /* best-effort: never let cleanup fail a good capture */ }
resolve('data:image/jpeg;base64,' + b64);
return;
}
waited += 150;
if (waited > (o.timeoutMs || 8000)) { reject(new Error('capture produced no file in ' + waited + 'ms')); return; }
global.setTimeout(tick, 150);
};
global.setTimeout(tick, 150);
});
},
requestSnapshot: function (opts) {
var o = opts || {};
return new Promise(function (resolve, reject) {
@ -625,6 +834,30 @@
onHostMessage: function (fn) { if (typeof fn === 'function') listeners.push(fn); },
/*
* Host diagnostics, routed into the channels the player already speaks.
*
* The host sees things the page has no API for the uptime, the wired IP, the video mode
* actually in force, which volume it booted from, whether a staged package applied and until
* now it printed all of it to a serial console. On a panel on a wall that is the same as not
* reporting it. A bad string literal once stopped this script compiling and the only evidence
* anywhere was on a cable; the server just saw a player that never appeared.
*
* These are deliberately thin: the bridge does not decide what a log line or an incident MEANS,
* it just carries them to the player, which sends them the same way it sends its own.
*/
onHostLog: function (fn) {
if (typeof fn !== 'function') return;
logSinks.push(fn);
drain(pendingLogs, fn);
},
onHostEvent: function (fn) {
if (typeof fn !== 'function') return;
eventSinks.push(fn);
drain(pendingEvents, fn);
},
/*
* Telemetry, read synchronously from a cache.
*
@ -658,6 +891,199 @@
} catch (e) { /* older OS without the call */ }
}
/*
* The address this player holds on the LAN the one an integrator needs to reach its DWS on
* site, and the field the dashboard has always had a slot for and never been able to fill.
*
* This is Node's own `os.networkInterfaces()`, which is what BrightSign's dev-cookbook does in
* both html5-app-template/src/info.ts and src-js/info.js. The widget is created with
* nodejs_enabled, so the standard library is simply there there is no @brightsign module for
* this, and looking for one is a dead end that cost a whole afternoon:
*
* @brightsign/networkconfiguration EXISTS but exposes only callback,
* getNeighborInformation and enableLeds no config reader at all.
* @brightsign/hostconfiguration has getConfig()/applyConfig(), but it returns HOST settings
* (forwardingEnabled, hostName, loginPassword, nameServers) with no address in them.
*
* Both verified by enumerating the live objects on our XT245 (FW 9.1.93.2), not from docs
* the docs pages for the JavaScript API 404, and their own roNetworkConfiguration page links
* to one of the dead URLs. getCurrentConfig() is BrightScript-only.
*
* `internal` is Node's own loopback flag, which beats string-matching 127.*; the 169.254
* link-local a player assigns itself when DHCP never answered is still filtered by hand,
* because sending an operator to an unreachable address is worse than showing nothing.
*
* family is compared loosely: it is the string "IPv4" on the Node in this firmware (and in
* the cookbook), but became the number 4 in Node 18, and this file outlives firmwares.
*/
if (osModule && typeof osModule.networkInterfaces === 'function') {
try {
var ifaces = osModule.networkInterfaces() || {};
var names = Object.keys(ifaces);
for (var ni = 0; ni < names.length; ni++) {
var addrs = ifaces[names[ni]] || [];
for (var ai = 0; ai < addrs.length; ai++) {
var a = addrs[ai];
if (!a || a.internal) continue;
var ip = String(a.address || '');
if (!ip) continue;
var isV4 = (a.family === 'IPv4' || a.family === 4);
var isV6 = (a.family === 'IPv6' || a.family === 6);
if (isV4 && !telemetry.local_ip && ip.indexOf('169.254.') !== 0) telemetry.local_ip = ip;
/*
* The v6 column has existed as long as the v4 one and has never held anything, on any
* player. The dashboard is already built for it it renders a second card ONLY when
* this is set, precisely so the overwhelmingly v4 fleet does not pay screen space for
* an empty row.
*
* fe80:: is skipped for the same reason 169.254 is: a link-local address is scoped to
* one interface and cannot be dialled from a laptop across the office, so reporting it
* would send someone somewhere they cannot go. A ULA (fd00::/8) is kept that IS
* reachable on the site network, which is the question this field answers.
*/
if (isV6 && !telemetry.local_ip6 && ip.toLowerCase().indexOf('fe80') !== 0) {
// Node appends a zone id to link-locals ("fe80::1%eth0"); strip any that survives.
var pct = ip.indexOf('%');
telemetry.local_ip6 = pct === -1 ? ip : ip.slice(0, pct);
}
}
}
} catch (e) { /* no networking yet, or a firmware without it — stay silent */ }
}
/*
* WHICH SCREEN IS PLUGGED IN, and what the output is actually driving.
*
* The first question about a dark sign is "which panel is that?", and until now the dashboard
* could not answer it: screen_width/height are what the PAGE believes it has, which is the
* widget's own geometry, not what the hardware negotiated with the display.
*
* The output is chosen by SCREEN NUMBER, because a dual-output player registers one device
* row per output (?screen=N, see output_index) and each row must report its OWN panel a box
* driving a lobby TV and a menu board would otherwise show the lobby TV twice.
*
* Both names are tried. Probed on an XT245 (FW 9.1.93.2): "hdmi" and "HDMI-1" both resolve to
* output 1 and answer with the same monitor, while a second output that does not exist fails
* cleanly "hdmi2" throws from the constructor and "HDMI-2" rejects. So a single-output
* player simply reports nothing here rather than inventing a screen.
*/
if (VideoOutputClass) {
var wantScreen = screenNumber();
var outNames = ['HDMI-' + wantScreen];
if (wantScreen === 1) outNames.push('hdmi');
for (var oi = 0; oi < outNames.length; oi++) {
try {
var vo = new VideoOutputClass(outNames[oi]);
if (!vo || typeof vo.getEdidIdentity !== 'function') continue;
var edid = vo.getEdidIdentity();
if (edid && typeof edid.then === 'function') {
edid.then(function (e) {
var mn = e && (e.monitorName || e.monitor_name);
if (typeof mn === 'string' && mn.trim()) telemetry.attached_display = mn.trim();
}, function () { /* no display on this output */ });
}
} catch (e) { /* no such output on this model */ }
}
}
/*
* The mode the output is negotiated to, which is not the same as the widget's size. Reported
* as WxH@Hz so it reads the way an installer would say it out loud. Our XT245 answers
* 1920x1200@60 the panel's native mode, while the page reports its own 1920x1080 canvas.
*/
if (VideoModeConfigClass) {
try {
var vmc = new VideoModeConfigClass();
if (vmc && typeof vmc.getActiveMode === 'function') {
var mode = vmc.getActiveMode();
if (mode && typeof mode.then === 'function') {
mode.then(function (m) {
if (!m) return;
var w = m.graphicsPlaneWidth || m.width;
var h = m.graphicsPlaneHeight || m.height;
var f = m.frequency || m.refreshRate;
if (w && h) telemetry.video_mode = w + 'x' + h + (f ? '@' + f : '');
}, function () { /* mode not readable on this firmware */ });
}
}
} catch (e) { /* older OS without the call */ }
}
/*
* Memory, load and REAL uptime all from the same Node standard library the address above
* came from, and all previously NULL on every BrightSign in the fleet.
*
* uptime deliberately OVERRIDES the page's own figure. index.html sends
* performance.now()/1000, which is how long this PAGE has been up; a widget rebuilt by the
* watchdog resets it while the player has been running for weeks. os.uptime() is the machine,
* which is what an operator reading "uptime" means and what makes a reboot loop visible.
*
* cpu_usage is the 1-minute load average normalised by core count and expressed as a
* percentage, so it is comparable with what the other players report rather than being a raw
* load figure that means nothing next to them. Clamped, because load can exceed core count.
*/
if (osModule) {
try {
if (typeof osModule.totalmem === 'function' && typeof osModule.freemem === 'function') {
var totalB = osModule.totalmem();
var freeB = osModule.freemem();
if (isFinite(totalB) && totalB > 0) telemetry.ram_total_mb = Math.round(totalB / 1048576);
if (isFinite(freeB) && freeB >= 0) telemetry.ram_free_mb = Math.round(freeB / 1048576);
}
if (typeof osModule.uptime === 'function') {
var up = osModule.uptime();
if (isFinite(up) && up > 0) telemetry.uptime_seconds = Math.round(up);
}
if (typeof osModule.loadavg === 'function' && typeof osModule.cpus === 'function') {
var la = osModule.loadavg();
var cores = (osModule.cpus() || []).length || 1;
if (la && isFinite(la[0])) {
var pct = Math.round((la[0] / cores) * 100);
telemetry.cpu_usage = pct < 0 ? 0 : (pct > 100 ? 100 : pct);
}
}
} catch (e) { /* a firmware without part of the stdlib — report what did work */ }
}
/*
* REAL disk, from statfs rather than the browser's storage quota.
*
* The quota is what this file used to report and it is not the disk: our XT245 answered
* "1026 MB total" for a 119 GB NVMe, because navigator.storage.estimate() describes the
* widget's cache budget. An operator reading that has been told something false about the
* machine, which is worse than an empty field.
*
* The volume is DISCOVERED, not assumed. BrightSign mounts storage under /storage (SD, SSD,
* USB), and which one a given player boots from varies ours runs from an NVMe while the
* card slot is dead. So statfs every mount and keep the largest, which is the content volume
* on every shape of player. Falls back to the widget's own working directory.
*/
if (fsModule && typeof fsModule.statfsSync === 'function') {
try {
var candidates = [];
try {
var mounts = fsModule.readdirSync('/storage') || [];
for (var mi = 0; mi < mounts.length; mi++) candidates.push('/storage/' + mounts[mi]);
} catch (e) { /* no /storage on this firmware */ }
candidates.push('/');
var bestTotal = 0, bestFree = 0;
for (var ci = 0; ci < candidates.length; ci++) {
try {
var st = fsModule.statfsSync(candidates[ci]);
if (!st || !isFinite(st.blocks) || !isFinite(st.bsize)) continue;
var tot = st.blocks * st.bsize;
// bavail is space usable by an unprivileged writer; bfree includes the reserve.
var fre = (isFinite(st.bavail) ? st.bavail : st.bfree) * st.bsize;
if (tot > bestTotal) { bestTotal = tot; bestFree = fre; }
} catch (e) { /* not a mount point */ }
}
if (bestTotal > 0) {
telemetry.storage_total_mb = Math.round(bestTotal / 1048576);
telemetry.storage_free_mb = Math.round(bestFree / 1048576);
}
} catch (e) { /* leave the quota estimate below to fill in */ }
}
/*
* REAL device storage, when the host could see a volume.
*

103
docs/licensing.md Normal file
View file

@ -0,0 +1,103 @@
# Licensing
ScreenTinker is MIT. This page records how we know what our dependencies are licensed under,
so the answer to "do you track licences?" is something you can check rather than something you
have to take on trust.
## The short answer
**No GPL or AGPL anywhere in the product.** Neither the server nor the Android player links,
bundles, or ships anything under strong or network copyleft.
## Where the answer comes from
Two gates run in CI on every push, and both fail closed — a dependency whose licence nobody has
recorded fails the build rather than shipping unnoticed.
| Gate | Covers | Script |
|---|---|---|
| Licence gate + SBOM (production deps) | the server's npm tree | `scripts/license-check.js` |
| Licence gate (APK runtime classpath) | everything that can enter the APK | `scripts/android-license-check.js` |
Run either locally:
```sh
cd server && npm ci --omit=dev && cd ..
node scripts/license-check.js # server
node scripts/android-license-check.js # APK
node scripts/license-check.js --sbom sbom/x.json # also write an SBOM
```
Neither script has dependencies of its own. A gate that needs its own supply chain audited is
worth less than one that doesn't.
## ⚠️ Audit the production install, not the checkout
**A licence scanner pointed at a developer checkout will report LGPL, and it will be wrong about
what we ship.**
`sharp` is a `devDependency` — a fixture generator for the image tests — and one of its platform
binaries, `@img/sharp-wasm32`, declares `Apache-2.0 AND LGPL-3.0-or-later AND MIT`. It is never
installed on a server: production installs with `npm ci --omit=dev`, which both the CI gate and
`scripts/upgrade.sh` use.
If someone challenges the answer with a scan of the repo, this is the discrepancy they have found.
`sharp` is kept deliberately: it is the *independent* implementation used to generate fixtures for
the pure-JavaScript image path that replaced it. Generating those fixtures with the library under
test would mean a decode bug could produce a fixture that hides the same bug.
## Policy
**Allowed** — MIT, MIT-0, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, Apache-2.0, BlueOak-1.0.0,
Unlicense, CC0-1.0, Python-2.0, WTFPL, Zlib, CC-BY-4.0.
**Denied** — AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence.
**Reported but not failed** — LGPL, MPL, EPL, CDDL, OSL, EUPL. Weak copyleft is file- or
library-scoped and usually fine when merely linked, but it is a judgement, and the judgement should
be made by someone who knows they are making it.
**Unrecognised — fails.** A package with no licence we can identify is not a package we ship. Where
a dependency ships a real licence *file* but declares no `license` field, it is recorded as an
exception in the script with the evidence that was read off disk (currently `exif-parser` and
`thirty-two`, both MIT).
### Why the JSON Licence is denied
`org.json:json:20090211` arrived transitively through `socket.io-client` and was **packaged into the
APK in full** — 19 classes, including ones nothing referenced. Its licence carries the clause *"The
Software shall be used for Good, not Evil"*: not OSI-approved, treated as non-free by Debian and
Fedora, and Category X at Apache. Not copyleft, but not a term to accept in a binary distributed
commercially.
It is now excluded in `android/app/build.gradle.kts`. Nothing is lost — Android has provided
`org.json` in the platform since API 1 and `minSdk` is 24 — and `android/licenses.json` denies it by
name so it cannot return quietly.
## SBOM
Every release publishes `screentinker-sbom-<version>.cdx.json`: **CycloneDX 1.5**, listing every
production dependency with its version, package URL, and licence. Generated from a production
install, so it describes what actually runs.
CI also uploads one as a build artifact on every run.
## Vendored code
Anything committed under `frontend/vendor/` **ships in the release tarball** and must carry its
licence notice as a separate file — minifiers strip headers, which is exactly when the notice has to
be kept alongside. See `frontend/vendor/README.md`.
## The GLSL transitions
The 14 shaders in `shared/Transitions/` are original work. Each carries its author and licence in
the file header, and none derives from Shadertoy, gl-transitions, glslsandbox or similar. "GL
Transitions v1" in those headers refers to the *interface convention* — the function signature the
renderer calls — not to borrowed code.
## Limits
These gates identify licences from declared metadata and recorded evidence. They are not a
clean-room provenance review, and they do not detect code copied into the repository without
attribution.

View file

@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: ScreenTinker Public API
version: 1.9.29
version: 1.9.36
description: |
Public, token-scoped REST API for ScreenTinker digital signage.
@ -122,9 +122,19 @@ components:
local_ip:
type: [string, "null"]
description: |
The device's **own address on its local network** (e.g. `192.168.1.42`), as
The device's **own IPv4 address on its local network** (e.g. `192.168.1.42`), as
reported by the player itself. This is the one to use to reach a panel directly on
site. Null on players that do not report it, or where the platform withholds it.
site. Null on players that do not report it, where the platform withholds it, or on
a panel with no IPv4 address at all — see `local_ip6`.
local_ip6:
type: [string, "null"]
description: |
The device's **own IPv6 address on its local network**, reported alongside
`local_ip` rather than instead of it: a dual-stack panel has both and either may be
the one you need. Link-local addresses (`fe80::/10`) are deliberately excluded —
every interface has one and none can be reached without also knowing the zone
index, so this carries a global or unique-local address or nothing. Null on players
that do not report it and on IPv4-only panels.
# --- Latest telemetry --------------------------------------------------------------
# Flattened from the most recent telemetry report. All null for a device that has
@ -673,7 +683,11 @@ paths:
widget_id: { type: string }
zone_id: { type: string }
sort_order: { type: integer }
duration_sec: { type: integer }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
responses:
'201': { description: Created playlist item. }
/playlists/{id}/items/reorder:
@ -815,7 +829,11 @@ paths:
content_id: { type: string }
widget_id: { type: string }
zone_id: { type: string }
duration_sec: { type: integer }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
sort_order: { type: integer }
responses:
'201': { description: Created item. }
@ -1242,7 +1260,11 @@ paths:
required: [content_id]
properties:
content_id: { type: string }
duration_sec: { type: integer }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
responses:
'200': { description: '{ success, devices_updated }.' }
/groups/{id}/assign-playlist:
@ -1560,7 +1582,18 @@ paths:
device_id: { type: string }
grid_col: { type: integer }
grid_row: { type: integer }
rotation: { type: integer }
rotation:
type: integer
enum: [0, 90, 180, 270]
default: 0
description: >-
How this panel is physically mounted, as degrees CLOCKWISE that its image
must be turned to come out upright on the wall (the same convention as a
device's `orientation`). canvas_* are in WALL space — the wall as the
audience sees it — so a portrait-mounted 1920x1080 panel is a tall tile
with rotation 90, and content needs no pre-rotating. Anything other than
0/90/180/270 is stored as 0. While a panel is in a wall this replaces its
own `orientation`, so the two can never rotate the content twice.
canvas_x: { type: number }
canvas_y: { type: number }
canvas_width: { type: number }

356
docs/operations.md Normal file
View file

@ -0,0 +1,356 @@
# Operations runbook
Running an instance day to day: deploying, verifying, rolling back, and the traps that have actually
cost people time.
The README covers the happy paths — [installing](../README.md#production-deployment),
[updating](../README.md#updating), [backups](../README.md#backups) and
[admin recovery](../README.md#admin-recovery). This is the part you want at 2am, or when a deploy
did not behave.
---
## Contents
- [Two deployment shapes](#two-deployment-shapes)
- [Before you deploy](#before-you-deploy)
- [Deploying: native (git + systemd)](#deploying-native-git--systemd)
- [Deploying: Docker](#deploying-docker)
- [The served APK](#the-served-apk)
- [Verifying a deploy](#verifying-a-deploy)
- [Rolling back](#rolling-back)
- [Releases and version numbers](#releases-and-version-numbers)
- [Upgrading Node.js](#upgrading-nodejs)
- [Traps worth knowing before they bite](#traps-worth-knowing-before-they-bite)
---
## Two deployment shapes
An instance is either **native** (a git checkout on a release tag, run by systemd) or **Docker** (a
published image, run by compose). They are not interchangeable, and the commands differ at every
step.
> ⚠️ **Know which one you are on before you type anything.** The most expensive mistakes in this
> runbook come from applying one shape's procedure to the other — `git checkout` on a Docker host
> changes nothing the container is running, and bumping an image tag on a native host does nothing
> at all. If you run more than one instance, keep a note of which is which somewhere you will read.
---
## Before you deploy
Every time, in this order:
1. **Snapshot the database.**
```bash
sqlite3 <db> ".backup /path/to/pre-<version>-$(date +%Y%m%d-%H%M%S).db"
sqlite3 /path/to/pre-<version>-*.db "PRAGMA integrity_check;" # want: ok
```
2. **Record the row counts** you intend to still have afterwards:
```sql
SELECT (SELECT COUNT(*) FROM devices), (SELECT COUNT(*) FROM users),
(SELECT COUNT(*) FROM content), (SELECT COUNT(*) FROM playlists);
```
3. **Check whether dependencies changed.** If `server/package.json` differs by more than the version
field between the running release and the target, you need an install step. If it differs only in
`"version"`, skip it — that is the cheapest and safest kind of deploy.
```bash
git diff <current-tag> <target-tag> -- server/package.json
```
4. **Check whether migrations will run.** They apply automatically at boot. Additive columns and new
tables are safe, and a code-only rollback simply leaves them unused.
```bash
git diff <current-tag> <target-tag> -- server/db/database.js | grep -E '^\+.*(ALTER|CREATE) TABLE|CREATE INDEX'
```
5. **Back up the compose file / the served APK** if you are about to change either.
---
## Deploying: native (git + systemd)
`scripts/upgrade.sh` does the whole sequence — snapshot, checkout, `npm ci --omit=dev`, restart, and
report the running version. It defaults to the newest **stable** tag, deliberately skipping
`-rc`/`-beta`/`-alpha` prereleases:
```bash
cd /opt/screentinker
scripts/upgrade.sh # latest stable release
scripts/upgrade.sh v1.2.3 # or pin one
```
If you are doing it by hand, the order matters:
```bash
sudo -u <service-user> git fetch --tags origin
sudo -u <service-user> git checkout -f v1.2.3
# only if dependencies actually changed:
cd server && sudo -u <service-user> npm ci --omit=dev
sudo systemctl restart <service>
```
**Ownership first.** Every file must belong to the service user *before* the checkout. A checkout
that fails partway through leaves the worst possible state: `VERSION` updated while the code is
still the old release, so the service reports a version it is not running and no migrations ran.
```bash
sudo chown -R <service-user>:<service-user> /opt/screentinker
```
**A service user with no home directory breaks npm.** It writes logs and a cache to `$HOME`, which
does not exist, and installs nothing while looking like it worked:
```bash
cd server && sudo -u <service-user> env HOME=/opt/screentinker \
npm_config_cache=/opt/screentinker/.npm-cache npm ci --omit=dev
```
**Prove the checkout is complete** — a version string alone will not tell you:
```bash
git status --porcelain --untracked-files=no # want: empty
git diff <tag> -- server frontend # want: empty
```
---
## Deploying: Docker
```bash
# in the compose directory
cp -a docker-compose.yml docker-compose.yml.bak-pre-<version>
sed -i 's|screentinker:<old>|screentinker:<new>|' docker-compose.yml
docker compose pull && docker compose up -d
```
Migrations run at boot exactly as they do natively. State lives in the named volume (`st-data` in
the example compose), so recreating the container does not touch the database.
Anything bind-mounted into the container — the served APK, a `.wgt`, custom assets — must be updated
on the **host**, and see the inode warning below.
---
## The served APK
The file the OTA endpoint hands to Android displays. Two rules, both learned the hard way.
**1. Replace it in place. Never `mv` or `cp` over it.**
It is a bind-mounted *file*, so the container holds the inode. Replacing the file gives the host a
new inode and the container keeps serving the old bytes forever, with nothing in any log to say so.
```bash
cat /tmp/new.apk > /opt/screentinker/ScreenTinker.apk # correct — same inode
# NOT: mv, cp, install, or anything that unlinks and recreates
stat -c %i /opt/screentinker/ScreenTinker.apk # confirm it did not change
```
**2. The advertised size must match the served bytes, or displays loop.**
`/api/update/check` reports `apk_size` from a cache refreshed every `OTA_APK_REFRESH_MS`
(default 60s), and the server re-stats at boot. If the advertised size and the real file disagree,
a display downloads, rejects, and retries — forever. After swapping, restart the service and confirm:
```bash
curl -s 'http://127.0.0.1:3001/api/update/check?version=<an-older-version>'
stat -c %s /opt/screentinker/ScreenTinker.apk # must equal the reported apk_size
```
> ⚠️ The query parameter is **`version`**, not `current_version`. The wrong name yields
> `reason: no-version, update_available: false`, which looks exactly like a broken OTA but is not.
> `/api/version` is a different endpoint and its `update_available` is not the OTA verdict.
**Verify the signature after any APK swap**, and use `jarsigner`:
```bash
jarsigner -verify ScreenTinker.apk # want: "jar verified."
unzip -l ScreenTinker.apk | grep META-INF # want: a .SF and a .RSA
```
`apksigner verify -v` misreports `v1 scheme: false` on some build-tools versions even when the JAR
signature is present and valid. MDM-managed signage needs v1, so trust `jarsigner`.
---
## Verifying a deploy
```bash
curl -s http://127.0.0.1:3001/api/version # version + build hash
curl -s http://127.0.0.1:3001/api/status # health, loop lag, connected displays
```
Then, and this is the part people skip:
- **Row counts match** what you recorded beforehand.
- **The log is clean.** Migrations reported, no errors:
```bash
docker logs <container> 2>&1 | grep -iE 'migrat|error|exception' # or journalctl -u <service>
```
- **Check through your reverse proxy / CDN too**, not only on loopback. Cached or misrouted assets
only show up from outside.
> ⚠️ **A version string is not proof the new code is running, and neither is the build hash.** The
> hash covers the frontend, so a server-only change deploys with an *unchanged* hash — which looks
> exactly like a stale image. When it matters, check for the code itself:
> ```bash
> docker exec <container> grep -c '<a symbol only the new version has>' /app/server/<file>
> ```
**A frontend change needs a hard refresh** (Ctrl+Shift+R) before you judge it. Assets revalidate,
but a browser sitting on the old bundle will show you the old behaviour and you will debug a fixed
bug.
---
## Rolling back
Because backups are taken per deploy, rollback is mechanical:
**Native**
```bash
sudo -u <service-user> git checkout -f <previous-tag>
cd server && npm ci --omit=dev # only if dependencies changed
cat /path/to/ScreenTinker.apk.bak > /opt/screentinker/ScreenTinker.apk
sudo systemctl restart <service>
```
**Docker**
```bash
cp -a docker-compose.yml.bak-<version> docker-compose.yml
cat /path/to/ScreenTinker.apk.bak > /opt/screentinker/ScreenTinker.apk
docker compose up -d
```
**The database usually does not need restoring.** Migrations are additive, so older code simply
ignores the new columns. Restore the snapshot only if a migration was destructive — and if one ever
is, that is the moment to stop and read it rather than reflexively rolling forward.
---
## Releases and version numbers
Cutting a release is documented in [RELEASING.md](../RELEASING.md). The operational consequences:
**A prerelease sorts BELOW its own release.** `1.2.3-alpha1` is semver-older than `1.2.3`. That has
two effects worth internalising:
- A display that takes a prerelease is not "ahead"; a later stable of the same version supersedes it,
which is what you want.
- The Android update check offers a prerelease to any older client on the **stable** channel. Putting
a prerelease on an instance means every Android display below it takes it at its next check. Do
that deliberately, on an instance whose displays you are willing to move.
**`:latest` is not moved for a prerelease.** The release workflow skips it for any tag containing a
`-`, so nobody tracking `:latest` pulls untested code on their next restart.
**Android `versionCode` must never go backwards.** Android refuses a downgrade, so a build with a
lower code cannot install over a higher one — the usual cause is a side-loaded test build whose code
was bumped past the release line. Keep the release line ahead of anything you side-load, or you will
be reinstalling by hand (which wipes app data and drops pairing).
**A re-cut tag is only safe if it published nothing.** If a tag has already produced a GitHub Release
or an image, delete-and-repush is not a fix; cut the next version instead.
---
## Upgrading Node.js
Upgrading the runtime is not like deploying a release: nothing in the app's own upgrade path is
involved, so the usual `scripts/upgrade.sh` never runs and nothing reinstalls dependencies. Read
this before changing the Node major.
**Do it as two separate deploys, never one.** Move the app to a release whose dependencies support
both the old and new Node major first, confirm it on the runtime you already have, and only then
change Node. Each half is then independently reversible. Doing both at once means a failure gives
you nothing to bisect and no single step to undo.
**Check the version floor.** `npm start` uses `node --env-file-if-exists=.env`. That flag reached
the Node 22 line only in **22.9.0** — it works on Node 20 because it was separately backported
there. On Node 22.022.8 the server refuses to start with `node: bad option`. Target 22.9.0 or
newer.
**One native module has to survive the move.** `better-sqlite3` is compiled against a single Node
ABI, so changing Node invalidates it. Two things make this survivable:
- `lib/preflight-deps.js` runs before anything else at boot, detects the mismatch by *opening a
database* (a bare `require` succeeds even on a wrong ABI, so it is not a valid check), and repairs
it with `npm rebuild better-sqlite3`.
- The pinned version ships **prebuilt binaries for both the current and the next Node major**, so
that repair downloads a binary instead of compiling one.
⚠️ **That second point is why the version is pinned exactly rather than with a caret**, and why
widening it is risky in a way `package.json` does not show. A version with no prebuild for your Node
falls back to a from-source `node-gyp` build — and because preflight rebuilds *synchronously before
the server listens*, a compile that outlives `TimeoutStartSec` turns `Restart=always` into a boot
loop that never finishes. Before changing that pin, check the project's release assets and confirm a
prebuild exists for every Node ABI you intend to run. A build toolchain (`python3`, `make`, `g++`)
should still be present as a fallback.
**Native (git + systemd)**
1. Back up first — a Node upgrade cannot corrupt the database, but you want the rollback anyway.
2. Change the Node major. If Node came from a distribution repository pinned to a major, the repo
definition itself must be repointed — upgrading the package alone can never cross majors, and
this pin lives in system configuration rather than in this repository.
3. `node --version` to confirm.
4. Rebuild the native module explicitly (`npm rebuild better-sqlite3` as the service user, in
`server/`), or let preflight do it on the next restart. Doing it by hand keeps the logs readable.
5. Restart, then verify as in [Verifying a deploy](#verifying-a-deploy). In the logs, confirm
preflight reports a successful rebuild rather than exiting.
**Docker** — nothing to rebuild. Change the base image, build, and deploy the new tag: dependencies
are installed inside the image against its own Node, so the ABI can never be stale. Rollback is
repinning the previous tag.
**Afterwards, move CI too.** CI pins its own Node version, and it will happily keep validating a
version nobody runs — which is worse than no signal, because it looks like coverage. The Docker base
image is a separate pin from the CI one; both need changing or what CI tests and what ships diverge.
---
## Traps worth knowing before they bite
**Native modules are built for one Node ABI.** `better-sqlite3` is compiled against the Node that
installed it. Run the app — or its tests — under a different major version and it fails with
`NODE_MODULE_VERSION` mismatch, which presents as hundreds of unrelated test failures rather than
one clear error. Use the same Node the service runs. See [Upgrading Node.js](#upgrading-nodejs)
before changing it deliberately.
**SQLite foreign keys are off unless enabled per connection.** A declared `ON DELETE CASCADE` does
not fire on its own, so deleting a parent row can leave orphaned children. Check with
`PRAGMA foreign_key_check;` after any bulk delete.
**Deploying reloads every connected web player.** The frontend self-reloads when the build hash
changes. Browsers cope. Some embedded webview players do not, and may need a restart afterwards —
worth knowing before you deploy during business hours.
**An SSO-linked administrator has no password.** If you link the platform administrator account to
an identity provider and that provider later fails, the login page cannot help you. Recovery is
`node scripts/reset-admin.js` on the server. See [sso-setup.md](sso-setup.md).
**Backups are only real once restored.** A snapshot that has never been restored is a hypothesis.
Periodically restore the newest one into a throwaway instance and confirm it boots and serves
`/api/status`.
**`curl … | sudo bash` answers the installer's questions for you.** The pipe *is* stdin, and bash
has consumed it before any prompt runs — so every question gets an instant end-of-input and the
script takes the default. On the Pi installer that meant the mode menu appeared to skip itself and
Player-Only was unreachable through the documented command. Fixed there (prompts read the terminal
now), but the trap is general: any piped installer that asks you something is not really asking.
Download the script and run it, or pass the answers as flags.
**A Raspberry Pi 5 on Bookworm runs Wayland, and X11 tools fail silently there.** `xset`,
`unclutter` and `xrandr` return an error and do nothing — so screen blanking is never suppressed and
the cursor is never hidden, while every command in your setup notes appears to have worked. If you
have hand-rolled kiosk tweaks on a Pi, check which session is actually running (`echo
$XDG_SESSION_TYPE`) before trusting them. The bundled launcher detects this and uses `wlopm` plus
`--ozone-platform=wayland` on Wayland.
**Overlay FS protects the SD card and discards everything written to it.** Reasonable on a
**player-only** Pi, where the loss is a content cache that simply re-downloads after each boot. Not
usable for an **all-in-one** install as-is: the server writes continuously — the SQLite database,
WAL, uploads and thumbnails — and a read-only root throws all of it away at reboot, so the instance
silently reverts to its state at the moment you enabled overlay. If you want both, put `DATA_DIR` on
a writable partition that overlay does not cover, and confirm a screen you add survives a power cut
before relying on it.

View file

@ -7,115 +7,343 @@ it puts a control on the dashboard that cannot work.
Capability names come from `server/lib/player-capabilities.js`. Players declare their own set at
registration; a player that declares nothing falls back to the per-platform baseline in that file.
**Legend** — ✅ supported · ⚠️ partial/conditional (reason given) · ❌ not supported (reason given)
**Legend** — ✅ verified in source · ⚠️ partial/conditional (reason given) · ❌ not supported (reason
given) · 💀 **dead**: the capability is declared or baselined but the control cannot work · ❓
**unverifiable from source** — needs hardware, and is marked as such rather than asserted.
BrightSign runs the *same* `server/player/index.html` as the browser, so it differs only where the
`autorun.brs` host bridge adds something the browser cannot reach.
`autorun.brs` host bridge adds something the browser cannot reach. The bridge has two halves: the
JS (`brightsign/st-bridge.js`, served by us at `/player/st-bridge.js`, always current) and the
on-device BrightScript that must create the widget with `nodejs_enabled:true`. `BS.hasHost()` is
false unless BOTH are present, and everything host-backed hangs off it.
Verified at `2237eda`. Where a row cites "the fielded build" it means `v1.9.28` — the last release
before any player declared anything, and therefore the build every baseline is describing.
---
## 🔴 Read this first: three dead controls found by this audit
These are not gaps in coverage. They are controls a customer can press today that do nothing.
### 1. The volume slider works on Android only
`frontend/js/views/device-detail.js` sends `set_volume` as **`{ level: 0..1 }`**:
```js
el?.addEventListener('change', () => sendCommand(device.id, cmd, { level: parseInt(el.value, 10) / 100 }));
```
| player | what the handler reads | result |
|---|---|---|
| Android | `payload.optDouble("level", -1.0)` | ✅ works |
| web | `payload.level` (fraction), `value` still read as a percentage | ✅ **fixed in 1.9.31** |
| Tizen | `payload.level` (fraction) | ✅ fixed in 1.9.31 — but see the note below on when the baseline may move |
| BrightSign | (the web player) | ✅ as web |
Three of the four players had a complete, working volume implementation that could not be driven,
because nobody checked the payload key against the sender. Fixed in 1.9.31: the fraction is now
canonical everywhere, and the scale is chosen by WHICH KEY arrived rather than by the magnitude of
the number (`1` is legal under both conventions, so guessing from the value is wrong for somebody).
`audio.volume` is back in the `web` and `brightsign` baselines as of 1.9.31, and **not** in the
`tizen` one. That asymmetry is the model, not an oversight — see
[When a baseline may move](#when-a-baseline-may-move).
### 2. Every #161 Tier-2 command was refused for the entire fleet — FIXED here
`lock_now`, `power_menu`, `status_bar`, `block_uninstall` and `unblock_uninstall` were gated on
`system.device_owner`. **No player declares that name** — not `PlayerCapabilities.kt`, not
`tizen/js/capabilities.js`, not `declaredCapabilities()`, not `st-bridge.js` — and no baseline
granted it. So `supports()` returned false for every device on every platform and all five commands
were refused, *including on the device-owner panels the whole feature was built for*. The dashboard
still drew the buttons, because `device-detail.js` gates that block on `device.tier === 2 ||` too,
so an operator on a real owner panel pressed "Lock now" and got a silent server-side refusal.
Fixed in `player-capabilities.js`: those five now accept `system.device_owner` **or**
`system.kiosk`. That is an exact stand-in, not a loose one — `PlayerCapabilities.kt` declares
`system.kiosk` under `if (isOwner)` and nothing else, which is precisely when `STPolicy`'s `owned()`
actions do anything, and no non-Android player declares it.
**Follow-up owned by Android:** `PlayerCapabilities.kt` should declare `system.device_owner` under
`if (isOwner)`, at which point the stand-in becomes redundant.
### 3. The capture bootstrap required the capability it creates — half FIXED here
`enable_system_capture` raises Android's MediaProjection consent dialog: it is how a panel *gains*
full-screen capture. It was gated on `remote.screenshot`, so the only panel that needs it — no
accessibility, no projection grant, therefore no declared `remote.screenshot` — was the one panel
that could not be sent it. The command is now ungated.
**Still broken, and it is a frontend change:** `device-detail.js` renders the button behind
`can('remote.screenshot')`, so it is still hidden on exactly those panels.
---
## Playback
No command routes to any `playback.*` capability and no dashboard control is gated on one, so these
describe content rendering. They are informational, and shown to the operator in the Info tab.
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `playback.video` | ✅ ExoPlayer | ✅ `<video>` | ✅ AVPlay | ✅ hardware plane |
| `playback.image` | ✅ | ✅ | ✅ | ✅ |
| `playback.video` | ✅ ExoPlayer (`MediaPlayerManager`) | ✅ `<video>` | ✅ AVPlay | ✅ hardware plane |
| `playback.image` | ✅ `ImageLoader` | ✅ | ✅ | ✅ |
| `playback.widget` | ✅ WebView | ✅ iframe | ✅ iframe | ✅ iframe |
| `playback.youtube` | ✅ WebView embed | ✅ IFrame API | ✅ iframe embed | ✅ IFrame API |
| `playback.zones` | ✅ | ✅ | ✅ | ✅ |
| `playback.transitions` | ✅ GL wipes (#204) | ⚠️ declared only when the bundle loads — a failed load hard-cuts rather than breaking playback | ✅ | ⚠️ as web |
| `playback.pip` | ✅ `PipOverlay` | ✅ `#pipContainer` | ✅ | ✅ |
| `playback.zones` | ✅ `ZoneManager` | ✅ | ✅ | ✅ |
| `playback.transitions` | ✅ `TransitionCompositor` | ⚠️ declared only when the bundle loads (`transitionRuntimeReady()`) — a failed load hard-cuts rather than breaking playback | ✅ `transitions.js` | ⚠️ composites DOM over video; with hwz it may be **invisible over video** and degrade to a hard cut |
| `playback.pip` | ✅ `PipOverlay` | ✅ `#pipContainer` | ✅ `pip-overlay.js` | ⚠️ same hwz caveat as transitions |
## Audio
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `audio.mute` | ✅ incl. YouTube via IFrame bridge | ✅ | ✅ incl. YouTube via `postMessage` | ✅ as web |
| `audio.volume` | ✅ `set_volume` | ✅ `set_volume` | ❌ **no `set_volume` handler exists** — the dashboard slider does nothing today | ✅ as web |
| `audio.mute` | ✅ `device:mute-changed``setVideoMuted`, incl. YouTube via the IFrame bridge | ✅ | ✅ incl. YouTube via `postMessage` | ✅ as web |
| `audio.volume` | ✅ `set_volume` reads `payload.level` | ✅ reads `payload.level` (1.9.31) | ✅ `applyVolume` reads `payload.level` (1.9.31, incl. `tizen.tvaudiocontrol`) | ✅ as web |
## Display
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `display.rotation` | ✅ native `rootView.rotation` | ✅ CSS transform | ✅ CSS + AVPlay for video | ⚠️ host rotates the output via `roVideoMode`; CSS alone cannot turn the hardware video plane |
| `display.power` | ✅ `screen_off` / `lock_now` | ❌ a browser tab cannot power a panel — the overlay only paints black | ❌ `screen_off` draws a black overlay, deliberately, "so the command still does something visible" | ⚠️ media teardown always works; CEC is best-effort and absent on some units |
| `display.resolution` | ❌ no video-mode control in the app | ❌ not addressable from a browser | ❌ | ✅ `roVideoMode` via the host |
| `display.rotation` | ✅ native `rootView.rotation` — the ExoPlayer surface rotates with it | ✅ CSS transform | ✅ CSS + AVPlay `setDisplayRotation` for video | ⚠️ CSS cannot turn the hardware video plane; the host would have to (`roVideoMode`), and the page never calls `BS.setVideoMode` |
| `display.power` | ⚠️ conditional. `screen_off` needs owner / device-admin FORCE_LOCK / accessibility; `screen_on` is a **wake lock**, which works anywhere — but only since `812e89f`. On the fielded build `screen_on` is a logged no-op, which is why the Android baseline no longer claims this | ❌ a browser tab cannot power a panel — the overlay only paints black | ✅ both halves on every build, no signing needed: `showScreenOff()` / `clearScreenOff()`, plus the real panel API where `STDeviceControl` finds one | ⚠️ needs `hasHost()`. Media teardown always blanks; ❓ **CEC is unverified** — our XT245 resolves `@brightsign/cec` while the kernel logs `failed to get cec clock` and the display never responds |
| `display.resolution` | ❌ needs system/root | ❌ not addressable from a browser | ❌ no web-accessible mode setting on the TV profile | ⚠️ **declared but unreachable**`st-bridge.js` exposes `setVideoMode`, the page never calls it, and no command maps to this capability |
| `display.brightness` (per-window dim, Tier 0) | ✅ `set_brightness``setWindowBrightness`, no privilege needed | ❌ | ❌ | ❌ |
⚠️ `PlayerCapabilities.kt` **does not declare `display.brightness`**, though `MainActivity` handles
`set_brightness` unconditionally. So an *updated* Android panel loses the per-window dim slider that
an un-updated one keeps via the baseline. See gap 2.
## Remote view and control
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `remote.screenshot` | ⚠️ view capture always; full-screen only with accessibility or MediaProjection | ⚠️ canvas only — same-origin content, and the alpha probe rejects frames where no pixels arrived | ✅ `captureAndSend` | ⚠️ host framebuffer capture **requires primary storage**; falls back to canvas, which cannot read the video plane |
| `remote.stream` | ✅ | ✅ 1fps | ✅ | ⚠️ as web |
| `remote.input` | ✅ | ✅ | ✅ | ✅ |
| `remote.screenshot` | ⚠️ `captureView` always (a real frame of the player's own view); full-screen only with accessibility or MediaProjection. Declared **only** for the full-screen path | ⚠️ canvas only — same-origin content, and the alpha probe rejects frames where no pixels arrived | ⚠️ `captureAndSend` captures **images only**; video and YouTube get an honest status card reading "Live preview unavailable for video / YouTube on Tizen" | ⚠️ `st-bridge.js` gates host framebuffer capture on **primary storage**; without a disk it falls back to canvas, which cannot read the video plane |
| `remote.stream` | ✅ | ✅ 1fps | ✅ 1s interval over `captureAndSend`, so the same image-only limit | ⚠️ as web |
| `remote.input` | ✅ `TouchInjector` — plain `dispatchTouchEvent`, no privilege | ✅ | ✅ `elementFromPoint().click()` + D-pad/volume keys | ✅ synthesised DOM events, needs no host |
## Lifecycle
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `system.restart_player` | ✅ | ✅ `location.reload()` | ✅ | ✅ host rebuilds the widget — a page reload does not reliably return |
| `system.reboot` | ✅ device owner | ❌ a browser tab cannot reboot its host | ❌ no Tizen API exposed to the app | ✅ `RebootSystem()` via the host |
| `system.self_update` | ✅ APK OTA (`UpdateChecker`) | ❌ the server deploys the player; there is nothing for it to update | ❌ `.wgt` updates go through Tizen's own store/CLI | ✅ `autorun.zip` package update |
| `system.restart_player` | ✅ `launch` / `refresh` | ✅ `location.reload()` | ✅ `location.reload()` via `STDeviceControl` | ⚠️ needs `hasHost()` so the host rebuilds the widget. **A page-initiated reload does not reliably bring an roHtmlWidget back** — that darkened a customer's panel on 2026-07-28, which is why neither `st-bridge.js` nor the baseline offers this without a host |
| `system.reboot` | ⚠️ **device owner only** (`STPolicy.reboot()`). Off-owner it degrades to an accessibility power *dialog*, which needs someone at the screen | ❌ a browser tab cannot reboot its host | ⚠️ only on a **partner-signed** panel where `STDeviceControl.capabilities().reboot` is true | ⚠️ `RebootSystem()` via the host |
| `system.self_update` | ✅ APK OTA (`UpdateChecker`), and `update` forces a check | ❌ the server deploys the player; there is nothing for it to update | ❌ a `.wgt` is installed by the panel, not the app | 💀 **for the dashboard button.** The host really does self-update — `autorun.brs` polls `CheckPackageUpdate` every `PKG_CHECK_MS` — but that is a host-side poll on a socket it is not listening to. The page declares `system.self_update` behind `hasHost()`, the dashboard renders "Force update", and `index.html` has **no `update` branch at all**. See gap 3 |
## Device management
Android device-owner territory. Everything here is ❌ elsewhere for the same reason — no equivalent
privilege model exists on those platforms — so the column is collapsed.
privilege model exists on those platforms — so the column is collapsed. Tizen and BrightSign both
decline these explicitly and in writing in their own capability modules.
| capability | Android | Web / Tizen / BrightSign |
|---|---|---|
| `system.kiosk` | ✅ lock-task, now persisted across reboot | ❌ no device-owner concept |
| `system.brightness` | ✅ Tier 0/1 | ❌ |
| `system.screen_timeout` | ✅ Tier 1 | ❌ |
| `system.install_apk` | ✅ Tier 2 | ❌ not an APK platform |
| `system.shell` | ✅ Tier 2, handled in `WebSocketService` | ❌ |
| `system.time` | ✅ Tier 2 | ❌ |
| `system.kiosk` | ⚠️ owner-only. Off-owner `startLockTask()` is screen pinning, which prompts — unusable on a panel with no input | ❌ no device-owner concept |
| `system.brightness` | ⚠️ `WRITE_SETTINGS` **or** owner (`setSystemSetting`) | ❌ |
| `system.screen_timeout` | ⚠️ same gate as above | ❌ |
| `system.install_apk` | ⚠️ owner **or** a foreign DPC that delegated the install scope | ❌ not an APK platform |
| `system.shell` | ✅ declared unconditionally — it is an **app-UID** `sh -c`, not root, so it works at any tier. Handled in `WebSocketService` | ❌ |
| `system.time` | ⚠️ owner-only | ❌ |
| `system.device_owner` | 💀 **declared by nobody.** See the red section above | ❌ |
## Synchronisation and resilience
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `sync.clock` | ✅ | ✅ | ✅ | ✅ |
| `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ SyncManager, BOS 8.2.10+; multicast so all members must share one L2 network |
| `offline.cache` | ✅ content downloaded to disk, **resumable** (Range + If-Range), revision-keyed | ✅ service worker, **resumable chunked prefetch**, revision-keyed | ✅ **media cached to `wgt-private`** (`js/media-cache.js`), resumable, revision-keyed — declared at runtime, since a build with no writable private storage must not claim it | ✅ inherits the web player's service worker |
| `sync.clock` | ✅ `GroupScheduleController` | ✅ | ✅ `syncedNow()` + `schedule-eval.js` | ✅ as web |
| `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ `st-sync.js` / SyncManager, gated on module presence **and** BOS 8.2.10+ (below the floor the module can resolve and silently do nothing, which on a wall means every panel reports healthy while drifting). ❓ **unverified on hardware** |
| `offline.cache` | ✅ `ContentCache` + `DownloadCoordinator`, resumable (Range/If-Range), revision-keyed | ✅ service worker, resumable chunked prefetch, revision-keyed; declared only when a worker is genuinely **controlling** the page | ⚠️ `js/media-cache.js` caches media to `wgt-private`**new at HEAD**, absent from the fielded build, and declared at runtime only where the platform grants storage | ❓ **unverified.** See gap 4 |
---
## Where the four declaration sites disagree with each other
| | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| declaration site | `telemetry/PlayerCapabilities.kt` | `declaredCapabilities()` in `index.html` | `js/capabilities.js` | **`index.html` again** |
⚠️ **`brightsign/st-bridge.js` `computeCapabilities()` IS DEAD CODE.** It is exported as
`BS.capabilities`, and nothing calls it: `grep -n "BS\.[a-zA-Z]*(" server/player/index.html` lists
23 bridge calls and `capabilities` is not among them. The BrightSign declaration actually comes
from the web player's `declaredCapabilities()`, and the two disagree substantially:
| capability | `st-bridge.js` says | `index.html` actually declares | which is right |
|---|---|---|---|
| `offline.cache` | `navigator.serviceWorker` **exists** | a worker is **controlling** the page | index.html. The bridge's version is the exact lie that shipped on the XT245 |
| `remote.screenshot` | needs `probe.storage_present` | any 2d canvas | the bridge. A canvas cannot read the video plane |
| `remote.stream` | needs `probe.storage_present` | unconditional | the bridge |
| `system.self_update` | needs `probe.storage_present` | needs `hasHost()` | the bridge — staging `autorun.zip` needs a volume |
| `display.rotation` | needs a host (`roVideoMode`) | unconditional (CSS) | the bridge, for video |
| `display.power` | needs `CecClass` | needs `hasHost()` | roughly equivalent |
| `display.resolution` | host **or** `VideoOutputClass` | needs `hasHost()` | the bridge |
| `system.restart_player` | needs a host | unconditional | the bridge — see the 2026-07-28 incident |
| `sync.native` | module **and** OS ≥ 8.2.10 | `ScreenTinkerBSSync.available()`, which is **module presence only** | the bridge. `index.html` skips the firmware floor |
**`server/test/brightsign-capabilities.test.js` is 199 lines of thorough tests for this dead
function.** Every one passes, and none of them constrains what a BrightSign actually declares. That
is worse than no coverage: it reads as proof.
The fix is small and belongs to whoever owns those files — have `declaredCapabilities()` return
`BS.capabilities()` when `BS.isBrightSign()`, and the storage/firmware gating that was already
written and tested starts being true.
---
## Real gaps worth closing
Ordered by how visible the failure is to an operator.
Prioritised by how visible the failure is to an operator.
1. **Tizen `audio.volume` — dead control.** `set_volume` has no handler in `tizen/js/app.js`; the
only volume path is the on-device `KEYCODE_VOLUME_*` keys. The dashboard slider silently does
nothing. Either implement the handler or let the capability hide the control.
2. ~~**Tizen `offline.cache` is partial.**~~ **Closed.** `tizen/js/media-cache.js` caches the
media itself to `wgt-private` — resumable, so a panel on a bad link accumulates an asset
across attempts instead of restarting from zero, and revision-keyed, so a replaced asset is
still a miss. The capability is declared at runtime rather than assumed: a build that cannot
write to private storage keeps quiet about it.
3. **BrightSign `remote.screenshot` needs primary storage.** Reachable today only via the canvas
fallback, which cannot read the video plane, so screenshots show everything except the video.
Resolves itself when a card or SSD is fitted.
4. **`display.resolution` is BrightSign-only.** Fine, but the dashboard should not offer it
elsewhere.
**Gaps 1, 2 and 5 are closed** (gap 1 shipped in 1.9.31; 2 and 5 are on
`fix/player-parity-small-gaps` and unreleased). ⚠️ **The baselines below have deliberately NOT been
moved** — per the rule in this document a baseline entry moves when the fix *reaches displays*,
which is the release AFTER the one carrying it. Moving them together would grant a capability to
every panel still running the old build.
⚠️ **Gaps 3 and 4 were implemented, audited, and REVERTED.** Both are still open, and both are now
known to be considerably more expensive than "small". Their rows record what the audit found, so the
next attempt starts from the traps rather than rediscovering them.
| # | gap | difficulty | why it matters |
|---|---|---|---|
| 1 | ✅ **`set_volume` payload mismatch** (`server/player/index.html`, `tizen/js/app.js`) — accept `level` (0..1) alongside `value` (0..100). | **trivial** — one line each | The volume slider is dead on 3 of 4 players. Highest visibility, lowest cost in the list. **Fixed and released in 1.9.31** (`volumeLevelFromCommand()`). |
| 2 | ✅ **`PlayerCapabilities.kt` under-declares.** Add `display.brightness` (Tier 0, `setWindowBrightness`, always available) and `system.device_owner` under `if (isOwner)`. | **trivial** | Updating an Android panel currently *loses* it the per-window dim slider, and keeps the Tier-2 stand-in in `player-capabilities.js` necessary. **Fixed** — both declared; the `system.kiosk` stand-in can retire one release after this ships. **The gap was wider than written**: `remote.screenshot` and `remote.stream` were gated on the accessibility service while `captureScreen()` falls through to `ScreenshotCapture.captureView`, a plain view draw with no permission check — so a Tier-0 panel *lost live view and screenshots by updating*, and a granted MediaProjection never became a capability at all (nothing re-declares on consent, so the operator granted it, capture started, and the server went on refusing). Both are now unconditional, matching the baseline's own reasoning. `display.power` remains conditional on purpose — see the DELIBERATE list in `player-parity-baselines.test.js`. |
| 3 | ❌ **REVERTED — BrightSign "Force update" is a dead button.** `index.html` has no `update` branch; the host self-updates on its own poll. | **NOT small — needs host work first** | Wiring the button to `CheckPackageUpdate` was tried and withdrawn. The update path is **synchronous and unbounded** (no `SetTimeout` on either transfer), so a slow failing download blocks the message loop past `WATCHDOG_MS` (120s), fabricating a crash event and rebuilding the widget. Worse, `MAX_ATTEMPTS_PER_VERSION` is 3 and the counter carries **no version binding**, so three presses on a bad link refuse that panel *every future version* until someone clears the registry by hand. `cfg.self_update` is not in the probe payload either, so an opted-out fleet shows a button guaranteed to do nothing while the dashboard toasts success — and `update` is allowed as a **group broadcast**, so one click can start N synchronous downloads. Prerequisites: a transfer timeout under the watchdog, a version-bound (or manual-exempt) attempt counter, a result message so the toast can tell the truth, and `self_update` in the probe. Until then, **withdrawing the claim is the cheaper honest fix.** |
| 4 | ❌ **REVERTED — `declaredCapabilities()` should defer to `BS.capabilities()` on BrightSign.** | **NOT small — the bridge's list is not a superset** | Deferring wholesale was tried and withdrawn: the two lists disagree in **both** directions. The bridge gates `remote.screenshot`/`remote.stream` on `storage_present` alongside `system.self_update`, but that reasoning is stale — the player captures via `@brightsign/screenshot` into **RAM** (`/tmp`) and falls back to canvas, so both work with no disk and deferring *removes working controls*. Only `system.self_update` is genuinely storage-gated. The bridge also declares `playback.transitions` unconditionally, where the page checks `transitionRuntimeReady()` — an over-declare on the one platform where the UMD/`nodejs_enabled` collision silently kills transitions. And `offline.cache` gets *looser*, not stricter: the bridge omits the page's `swRegistrationFailed` check. Compounding all of it, `probeHost`'s 3s timeout sets `answered = true`, so a late `probe-result` is discarded **for the page's lifetime** — and `autorun.brs` runs a blocking update check *before* entering the message loop, making a >3s answer plausible. A correct fix is a per-capability MERGE, not a wholesale hand-off, plus fixing the probe timeout. |
| 5 | ✅ **The capture-bootstrap button is hidden where it is needed** (`device-detail.js`, `can('remote.screenshot')`). | **small** | Server-side gating is fixed; the UI half is not. **Fixed** via `isAndroidDevice()`, mirroring `platformFamily()` in `server/lib/player-capabilities.js` — all four signals in the same order, since a Tizen TV registers `android_version: 'Tizen 6.5'` and an Android-test-only helper classifies every Samsung panel as Android. ⚠️ The gate is Android-and-nothing-else, **not** "Android that lacks `remote.screenshot`": `/api/devices/:id` ships `capabilitiesFor()`, which flattens declared and baseline into one array, and the android baseline *contains* `remote.screenshot` — so that condition hides the button from all ~440 undeclared panels. The dashboard cannot currently distinguish "declared" from "baseline-filled" at all; if a future gate needs that, the API must expose the raw declaration. |
| 6 | **Tizen `remote.screenshot` is images-only.** Video and YouTube return a status card. AVPlay has no readable surface for a canvas. | **hard**, possibly impossible | Honest today, but an operator checking a video panel gets a card instead of a picture. |
| 7 | **BrightSign transitions/PiP over video.** DOM composited over a hwz hardware plane may be invisible. The likely fix is `roVideoMode.SetGraphicsZOrder("front")`, deliberately not applied blind. | **medium**, ❓ **needs hardware** | Changing z-order blind risks hiding video entirely on a player that currently works. |
| 8 | **BrightSign offline caching is unproven either way.** See below. | ❓ **needs hardware** | |
### The BrightSign `offline.cache` question, stated honestly
A real XT245 on alpha exposes `navigator.serviceWorker`, and then never even fetches `sw.js`:
registration is refused, so there is no worker, no content cache and no offline playback. That unit
runs **BSN Supervisor** (`autorun.createdby = Supervisor 2.1.18.3`) rather than our
`brightsign/autorun.brs`, and Supervisor's widget has no `storage_path` — the setting our own host
script does set (`storage_path: "/cache"`, `storage_quota: "1073741824"`) and the precondition for a
widget having persistent storage at all.
So this is *very likely* a widget CONFIG issue rather than a platform limit. **It is unverified: no
one has yet watched a player running our package register a worker.** Until someone has, this
document does not claim it, the `brightsign` baseline does not grant it, and the player declares it
only when a worker is genuinely in control — a refused registration reports
`app_error/sw_unavailable` to the server rather than a `console.warn` on a display nobody has a
console for.
## Correctly impossible — do not "fix" these
- **`system.reboot` on web/Tizen.** No API exists. A browser tab rebooting its host would be a
browser vulnerability.
- **`system.reboot` on web.** No API exists. A browser tab rebooting its host would be a browser
vulnerability.
- **`display.power` on web.** The overlay is the honest maximum; the panel stays lit.
- **All of device management off Android.** No equivalent privilege model exists on Tizen or
BrightSign, and a web player has no device to manage.
- **Device management off Android.** No equivalent privilege model exists on Tizen or BrightSign,
and a web player has no device to manage.
- **`system.self_update` on web.** The player *is* the deployment; there is nothing to update.
- **`sync.native` off BrightSign.** It is BrightSign's own protocol, and the clock-derived one is
the cross-platform answer that already works everywhere.
- **`display.resolution` off BrightSign.** No other platform exposes mode setting to an app.
## ⚠️ Corrections needed in `player-capabilities.js`
---
Found while verifying this table. The baselines only apply to displays that declare nothing, so
these are wrong for the existing fleet until each player ships its declaration:
## Baselines: what an un-updated display is assumed to be able to do
- **`tizen` claims `audio.volume`** — no handler exists (gap 1 above). Should be removed.
- **`tizen` omits `remote.screenshot` and `remote.stream`** — both are implemented
(`captureAndSend`, `startStreaming`). Should be added.
- **`tizen` declares `offline.cache` itself now** — the server baseline still omits it, which is
correct: a fielded panel that has not been updated genuinely cannot hold media, and the
baseline describes what an un-updated one can do.
~446 fielded displays declare nothing and fall back to `BASELINE` in
`server/lib/player-capabilities.js`. Because v1.9.29 is the first build in which *any* player
declares anything, every display reading a baseline is running **v1.9.28 or older by construction**
— so each entry below is justified against `git show v1.9.28:<player source>`, not against HEAD.
`server/test/player-parity-baselines.test.js` pins these to the player sources.
### Corrections made in this pass
| baseline | change | evidence |
|---|---|---|
| `android` | **removed `display.power`** | v1.9.28 `MainActivity`: `"screen_on" -> Log.w("no privileged wake path on a non-rooted panel — no-op")`. The ON half is dead on 100% of fielded panels, and one capability renders **both** buttons. |
| `android` | **removed `system.reboot`** | `STPolicy.reboot()` requires device owner; off-owner v1.9.28 shows the accessibility power *dialog* — which on the accessibility-enabled panels common in this fleet paints that dialog **over the signage**. Owner provisioning is unreleased (#161 / PR #168 still open), so "device owner AND pre-1.9.29" is effectively an empty set. |
| `tizen` | **added `display.power`** | v1.9.28 `app.js` implements both halves with no signing and no panel API: `showScreenOff()` / `clearScreenOff()` + `keepAwake()`. Unlike Android, neither half is privilege-gated. Withholding it hid a working control on every Tizen panel. |
| `web` | **removed `audio.volume`, then RESTORED it in 1.9.31** | Removed when v1.9.28 `index.html` contained the string `set_volume` zero times. Restored once the handler landed — this player is served by the server, so there is no fielded build to lag behind. |
| `brightsign` | **removed `display.power`, `system.reboot`, `system.restart_player`, `offline.cache`** (and `audio.volume`, restored in 1.9.31 with `web`) | All five need a host bridge (`hasHost()`) or a service worker that a Supervisor-built widget refuses. `system.restart_player` is the 2026-07-28 panel-blackout path. `offline.cache` is the documented lie this whole model exists to stop. |
### When a baseline may move
A baseline describes what an **un-updated** display can do, so the question "has this shipped?"
has two different answers depending on how the player reaches the screen.
**Served by the server — `web`, `brightsign`.** The player is a document this server hands out. A
display running against this build *is* running this build's player; there is no such thing as a
browser panel stuck on last release's. So the baseline moves the moment the server ships the fix,
and holding it back hides a control that already works. `test/player-parity-baselines.test.js`
judges these two against the working tree, in **both** directions.
**Shipped as a device artifact — `android`, `tizen`.** The player is an APK or a `.wgt` sitting on
the panel. Cutting a release puts nothing on any screen; a panel updates when somebody updates it,
and this repo cannot know how many are still back on which build. These are judged against the
**previous release**, and only in the over-claim direction: "the baseline claims it, so the shipped
player had better implement it" is always worth failing on, while "HEAD gained the handler, so add
it to the baseline" is a guess about the fleet, not a fact about it. A panel that HAS updated
declares its own capabilities and never reads the baseline at all.
The cost of the one-directional rule is that a stale entry can sit here after the artifact really
has reached the fleet. That is a judgement call about panels, so a person makes it in
`server/lib/player-capabilities.js` and records why — which is what the Tizen `audio.volume` note
there is doing right now.
> This distinction was learned the hard way. The test used to read "shipped" as *the newest tag*,
> which is HEAD on a release commit — so tagging 1.9.31 flipped every biconditional at once and
> demanded a baseline change for displays that could not possibly have the fix yet. The build went
> red naming a baseline, with nothing in the diff to explain it.
### Consequence, deliberately accepted
`server/services/scheduler.js` gates the nightly scheduled reboot on `system.reboot`. Removing it
from the Android baseline means scheduled reboots now **no-op for undeclared Android panels**
instead of logging `scheduled reboot fired` for a panel that never rebooted. That log line is the
stated reason the gate exists; skipping is the honest answer, and an owner panel on v1.9.29+
declares `system.reboot` for itself and is unaffected.
### The resulting baselines
| capability | android | tizen | brightsign | web |
|---|---|---|---|---|
| `playback.*` (all 7) | ✅ | ✅ | ✅ | ✅ |
| `audio.mute` | ✅ | ✅ | ✅ | ✅ |
| `audio.volume` | ✅ | ❌ the fielded `.wgt` has no handler | ✅ since 1.9.31 (runs the served player) | ✅ since 1.9.31 |
| `display.rotation` | ✅ | ✅ | ⚠️ graphics only | ✅ |
| `display.power` | ❌ `screen_on` is a no-op | ✅ | ❌ needs host | ❌ |
| `display.brightness` | ✅ Tier 0, since v1.9.10 | ❌ | ❌ | ❌ |
| `remote.screenshot` / `remote.stream` | ✅ view capture | ✅ images only | ❌ no video plane | ✅ |
| `remote.input` | ✅ | ✅ | ✅ | ✅ |
| `system.restart_player` | ✅ | ✅ | ❌ widget may not return | ✅ |
| `system.self_update` | ✅ | ❌ | ❌ needs host | ❌ |
| `system.reboot` | ❌ owner-only | ❌ | ❌ needs host | ❌ |
| `sync.clock` | ✅ | ✅ | ✅ | ✅ |
| `offline.cache` | ✅ | ❌ playlist JSON only | ❌ ❓ unverified | ✅ |
Everything conditional at runtime on every platform that has it at all — `system.kiosk`,
`system.brightness`, `system.screen_timeout`, `system.install_apk`, `system.shell`, `system.time`,
`system.device_owner`, `sync.native`, `display.resolution` — is absent from **every** baseline, and
a test enforces that.
## What is tested, and what cannot be
`server/test/player-parity-baselines.test.js` reads the player sources and fails when they and the
claims disagree:
- every baseline and command-map name is in the vocabulary, and no baseline has duplicates;
- **the dead-button rule** — every gated command has a branch in some player;
- **the unreachable-capability rule** — every gating capability is either declared by some player's
source or granted by some baseline. *This is the test that would have caught the
`system.device_owner` bug*;
- a device-owner Android panel can actually be sent all five Tier-2 commands, and an ordinary one is
still refused them **by name**;
- `audio.volume` and `offline.cache` are **biconditional** against the player sources, so a fix in a
player fails the test until the baseline is updated;
- no baseline claims a conditional capability, and the BrightSign baseline claims nothing behind
`hasHost()`;
- every capability-shaped string quoted in any player is one the server knows — the server's parser
*drops* unknown names, so a typo silently removes a control rather than raising anything.
**Not testable from source, and asserted nowhere:** whether CEC reaches a real display; whether a
widget built by our own `autorun.brs` is permitted to register a service worker; whether SyncManager
genuinely holds a wall in frame lock; whether transitions and PiP are visible over a hwz video
plane. Each is marked ❓ above and needs hardware.

263
docs/sso-setup.md Normal file
View file

@ -0,0 +1,263 @@
# Single sign-on — setup guide
How to turn on SSO, for the two people who need it: the **operator** running the server, and an
**organization admin** bringing their company's own identity provider.
Everything below is OpenID Connect. One flow — Authorization Code with PKCE, completed server-side —
so the browser never talks to the provider directly and there is no SDK to load.
---
## Contents
- [Which kind of SSO do you want?](#which-kind-of-sso-do-you-want)
- [Operator: Google](#operator-google)
- [Operator: Microsoft / Entra ID](#operator-microsoft--entra-id)
- [Operator: any other provider](#operator-any-other-provider)
- [Organization admin: bring your own provider](#organization-admin-bring-your-own-provider)
- [Requiring SSO for your organization](#requiring-sso-for-your-organization)
- [Linking an existing account](#linking-an-existing-account)
- [What users see at sign-in](#what-users-see-at-sign-in)
- [Troubleshooting](#troubleshooting)
---
## Which kind of SSO do you want?
There are two, and they are configured in completely different places.
| | Instance-wide | Per-organization |
|---|---|---|
| Configured by | the **operator**, in environment variables | an **org owner/admin**, in Settings → Single sign-on |
| Restart needed | yes | no |
| Who sees the button | everyone, on the login page | only people at that organization's **verified** domains |
| Typical use | "Sign in with Google" for anyone | a customer wiring up their own Entra/Okta tenant |
An organization's provider **overrides** the instance's for its own verified domains, and never
appears publicly — the login page reveals it only after someone enters an address at one of those
domains, so a guessed domain cannot confirm who your customers are.
---
## Operator: Google
Google is the simplest: one fixed issuer, and it reports whether an address is verified.
1. **console.cloud.google.com** → create a project (a dedicated one — if you reuse an auto-created
AI Studio project and later tidy those up, you take sign-in down with it).
2. **Google Auth Platform** (formerly "OAuth consent screen"):
- **App name** — users see this on the consent screen
- **Audience**: External
- Support and contact email
- Scopes: nothing to add. `openid`, `email` and `profile` are implicit and non-sensitive, so
**no Google verification review is required**.
3. **Clients → Create client → Web application**
- **Authorized redirect URI**, exactly:
```
https://your-domain.example/api/auth/oidc/google/callback
```
- Leave *Authorized JavaScript origins* empty — the exchange is server-side.
4. **Audience → Test users**: while publishing status is *Testing*, only listed accounts can sign
in. Add yourself, or **Publish app** (safe here, given the scopes).
5. Set the environment:
```bash
GOOGLE_CLIENT_ID=…apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=… # a Web application client needs one
```
> Google matches redirect URIs byte for byte. No trailing slash, `https` not `http`.
---
## Operator: Microsoft / Entra ID
Microsoft needs one decision up front: **whose accounts are signing in?** That decides both the app
registration and, crucially, the tenant ID you configure.
### The rule that catches everyone
`MICROSOFT_TENANT_ID` is **not** "where the app is registered". It is the directory that
**authenticates the user**, because it is what the ID token's `iss` will say. Those are different
things whenever the two differ — most obviously for personal accounts.
| Who signs in | Supported account types | `MICROSOFT_TENANT_ID` |
|---|---|---|
| Personal Microsoft accounts (outlook.com, hotmail, …) | **Personal Microsoft account users** | `9188040d-6c67-4c5b-b112-36a304b66dad` (Microsoft's consumer directory) |
| Your own staff | **Single tenant** | your **Directory (tenant) ID** |
⚠️ **`common`, `organizations` and `consumers` are refused, deliberately.** Two reasons that point
the same way. They cannot work: Microsoft's multi-tenant metadata advertises the issuer as the
literal template `https://login.microsoftonline.com/{tenantid}/v2.0`, so `iss` can never match. And
the obvious workaround is dangerous — accepting that template means accepting tokens from *every*
Azure tenant, which is [nOAuth](https://www.descope.com/blog/post/noauth): any tenant admin can set
an arbitrary, unverified `email` on one of their own users and be issued a session as that address.
Safe multi-tenant support needs per-tenant pinning (allowlist `tid`, key accounts on `oid`+`tid`
rather than email) and is not implemented. Setting one of these disables Microsoft sign-in with a
warning at boot rather than failing quietly.
### Steps
1. **portal.azure.com** → Entra ID → App registrations → **New registration**
- **Name** — users see this on the consent screen
- **Supported account types** — per the table above
- **Redirect URI**: platform **Web**, value
```
https://your-domain.example/api/auth/oidc/microsoft/callback
```
⚠️ **Web, not SPA.** A SPA registration is rejected at the token endpoint, because this exchange
runs server-side and sends no browser `Origin`.
2. **Certificates & secrets → New client secret** → copy the **Value** (shown once, not the ID).
A Web registration is a confidential client; the exchange fails without it.
3. **Token configuration → Add optional claim → ID → `email`.** Without it the token can arrive with
no address at all, which fails as `no_email`.
4. Set the environment:
```bash
MICROSOFT_CLIENT_ID=…
MICROSOFT_TENANT_ID=… # see the table — NOT necessarily the directory the app lives in
MICROSOFT_CLIENT_SECRET=…
```
> **Entra never sends `email_verified`.** ScreenTinker treats a tenant-pinned Microsoft provider as
> vouching for the address rather than demanding a claim Microsoft does not emit — safe because the
> operator chose that provider and it is pinned to one directory. An explicit `email_verified: false`
> is still refused.
---
## Operator: any other provider
Okta, Auth0, Keycloak, Authentik, Zitadel — anything with a discovery document:
```bash
OIDC_PROVIDERS=okta,authentik # comma-separated slugs
OIDC_OKTA_ISSUER=https://example.okta.com # the base URL whose /.well-known/openid-configuration describes it
OIDC_OKTA_CLIENT_ID=…
OIDC_OKTA_CLIENT_SECRET=… # optional — PKCE means a public client works
OIDC_OKTA_NAME=Okta # optional button label
OIDC_OKTA_SCOPES=openid email profile # optional
OIDC_OKTA_ASSUME_EMAIL_VERIFIED=true # only if it verifies addresses but omits the claim
```
Redirect URI is `https://your-domain.example/api/auth/oidc/<slug>/callback`.
Set **`APP_URL`** so the redirect URI is pinned to one origin. It must match your provider's
registration exactly, and deriving it from the request `Host` would both break behind a second
hostname and take its value from the caller.
---
## Organization admin: bring your own provider
No environment variables, no restart, no operator involvement.
1. **Settings → Single sign-on → Add provider**
- **Issuer** — for Entra, `https://login.microsoftonline.com/<your-tenant-guid>/v2.0`
- **Client ID** and **Client secret** from your own app registration
- **Email domains** you intend to claim
2. Copy the **redirect URI** shown in Settings and register it with your provider. It carries a
generated slug, so two customers can neither collide on nor guess each other's.
3. **Verify each domain.** Publish the TXT record shown:
```
_screentinker-verify.<your-domain> TXT st-verify=<token>
```
Then press Verify. An unverified claim lapses after 8 hours and releases the domain.
Your provider may only assert addresses at domains you have **proved** you control. A domain can be
claimed by one organization only; a second claim is refused.
> Proof by CNAME is not accepted — it would need a wildcard zone we do not operate, and would turn a
> subdomain takeover into an apex takeover.
Once a domain is verified, your provider is trusted to assert addresses in it even if it omits
`email_verified` (as Entra does) — the DNS proof stands in for the claim. A provider that has
verified nothing assumes nothing.
---
## Requiring SSO for your organization
**Settings → Single sign-on → Require single sign-on.** Then, for anyone at your verified domains:
- passwords are refused
- other providers are refused, **including the instance's own Google/Microsoft** — otherwise
"requires SSO" would just be renaming the bypass
⚠️ **Enabling this clears the passwords** of members at your verified domains. That is not reversible
without a reset.
Turning it **off** requires a platform administrator to approve the request, so one compromised org
admin cannot quietly reopen password login. Plan for that turnaround before you enable it.
---
## Linking an existing account
Signing in with a provider never takes over an account that already has a password — otherwise
anyone who could get a provider to assert your address would inherit your account. Link it
deliberately instead:
**Settings → Sign-in method → Link `<provider>`**
- An account has **one** credential. Linking **deletes** the password; afterwards you sign in with
the provider only.
- **Unlink** asks for a new password and applies both changes together, so the account is never left
without a way in.
- The provider account must use the **same email address** as the ScreenTinker account.
- Only the providers this server offers can be linked — an organization's own provider cannot attach
itself to an account.
> ⚠️ If you link the **platform administrator** account, that provider becomes the only way in.
> Should it break, recovery is `scripts/reset-admin.js` on the server, not the login page.
---
## What users see at sign-in
The login page asks for an email address first and shows the password box only after you continue.
That is what lets it check whether the address belongs to an organization with its own provider
*before* offering a credential — so someone whose company requires SSO is shown that, rather than a
password box that was going to be refused. Correcting the address takes you back a step.
The instance's own providers are shown throughout.
---
## Troubleshooting
Errors appear as a message on the login page (or Settings, when linking). The exact code is in the
URL as `sso_error=…`, and the server logs a matching `[oidc]` line with the underlying reason.
| Code | What it means | Usual cause |
|---|---|---|
| `unknown_provider` | No such provider on this server | Slug typo; or `MICROSOFT_TENANT_ID` is multi-tenant, so Microsoft was disabled at boot — check the `[sso]` warning |
| `provider_unavailable` | Discovery or the token exchange failed | Wrong issuer URL; no outbound network; **missing client secret** on a confidential client |
| `provider_refused` | The provider itself said no | Consent declined; conditional-access policy; account not on the Google test-user list |
| `expired` | The round trip took too long | Left the tab open; started over in another tab |
| `bad_state` / `no_code` | The response did not match the request | Started in one browser and returned in another; a redirect URI that does not match the registration |
| `verification_failed` | The ID token did not verify | **Wrong tenant** — the log prints the `iss` actually seen; clock skew; wrong client ID |
| `no_email` | The token carried no address | Entra: add the **`email`** optional claim under Token configuration |
| `email_unverified` | The provider would not vouch for the address | The provider sent `email_verified: false`; or it omits the claim and is not eligible to assume (an org provider with no verified domain) |
| `account_exists_local` | That address already has a password | Sign in with the password, then **Settings → Sign-in method → Link** |
| `account_exists_other_provider` | The account belongs to a different provider | Unlink first, or sign in with the provider that owns it |
| `subject_mismatch` | Same address, different provider subject | The address was reassigned. Deliberate: it stops a recycled mailbox inheriting an account |
| `domain_not_allowed` | The provider asserted a domain it has not verified | Verify the domain, or check which address the provider is actually sending |
| `sso_required` | The organization requires its own provider | Use the organization's button, not the password box or an instance provider |
| `registration_disabled` | New accounts are turned off | The address has no account and self-registration is disabled |
| `link_email_mismatch` | The provider account has a different address | Sign in to the provider with the same address as the account |
| `link_already_used` | That provider identity is linked elsewhere | Unlink it from the other account first |
### Checks worth doing first
```bash
# What the server thinks is configured (public endpoint)
curl -s https://your-domain.example/api/auth/config
# Does the start URL carry the right issuer and redirect?
curl -s -o /dev/null -D - https://your-domain.example/api/auth/oidc/google/start | grep -i location
# Boot warnings, and every login outcome
docker logs <container> 2>&1 | grep -E '\[sso\]|\[oidc\]'
```
`/api/auth/config` reporting `microsoftEnabled: false` while `MICROSOFT_CLIENT_ID` is set almost
always means the tenant ID was rejected — look for the `[sso]` line at boot.

112
docs/telemetry.md Normal file
View file

@ -0,0 +1,112 @@
# Install statistics
ScreenTinker can optionally report how many screens an install runs. It is **off until you turn it
on**, and this page documents the whole of it.
---
## What is sent
Three fields. This is the complete payload:
```json
{
"instance_id": "9f2c1b6e-4a17-4c8e-9d3b-27a5e0f81c44",
"version": "1.9.34",
"screen_count": 42
}
```
| Field | What it is |
|---|---|
| `instance_id` | A random UUID generated by your server on first use and kept in its own database. It carries no information about you — its only job is to let two reports from the same server be recognised as the same server, so a count is a count rather than a sum of duplicates. |
| `version` | The ScreenTinker version this server is running. |
| `screen_count` | How many displays have been paired with this server. |
## What is not sent
No hostnames, IP addresses or domains. No organization, workspace or user names. No email
addresses and no user count. No device names, locations or serial numbers. No content, filenames,
playlists or schedules. No logs and no configuration.
The request is sent over HTTPS, and the receiving service does not record the source address.
## Verifying that
Rather than take the above on trust:
- **In the product** — Settings → Install statistics shows the exact payload your server would
send, generated live from your own data, plus what it last actually sent and when.
- **In the source** — the payload is built in one function, `payload()` in
[`server/lib/telemetry.js`](../server/lib/telemetry.js). Every field that leaves your server
is in that object literal. `server/test/telemetry.test.js` fails if a field is added.
- **On the wire** — the destination is a single `POST`, overridable with `TELEMETRY_ENDPOINT`, so
you can point it at your own collector and read exactly what arrives.
## Turning it on or off
You are asked once, on the dashboard, if you are a platform administrator. Both answers are
remembered, so declining is permanent and you will not be asked again after an update.
To change your mind at any time: **Settings → Install statistics**.
Reports are sent 5 minutes after the server starts, then once a day while it keeps running.
Nothing is queued or retried — if your server is offline or the request fails, that attempt is
simply skipped.
## If your outbound traffic is filtered
Reports are an ordinary HTTPS `POST` from your server to:
```
https://stats.screentinker.com/api/telemetry/report
```
Many self-hosted servers sit on networks that block outbound connections by default. **If yours
does, that address has to be allowed or the reports never arrive** — sharing will appear to be on
while nothing reaches us.
You do not have to guess whether that is happening. Turning sharing on sends a report immediately,
so a blocked connection is reported there and then, and **Settings → Install statistics** names the
failure and the address to allow.
Nothing needs to be opened *inbound*. This is an outbound connection from your server only.
## Keeping your own copy
If you want these numbers for your own fleet, set `TELEMETRY_EXTRA_ENDPOINT` to your own collector.
Your server then posts the same three fields there as well.
Two things to be clear about, because the naming is deliberate:
- **It is additional, not a redirect.** Setting it does not stop the shared report going to
ScreenTinker — that is why it is called `EXTRA` rather than `ENDPOINT`. Settings lists every
destination a report goes to, so what is configured is always visible.
- **It is independent of the sharing switch.** Your collector receives reports whether sharing is
on or off, because that is your server posting to your host. **If you want your own statistics
and nothing sent to us, set it and leave sharing off** — that combination is supported on purpose.
Each destination is attempted separately, so one being unreachable never stops the other.
## Why we ask
ScreenTinker is self-hostable, so most installs are invisible to us by design, and that is how it
should stay. The cost is that we genuinely cannot answer "how many screens run this?" — a question
that matters for arguing the project is worth continuing to build, and for deciding which players
deserve the next round of work.
Sharing is a small, specific way to help with that. Declining is a completely reasonable answer and
changes nothing about how the product works.
> **A note on honesty.** Because sharing is opt-in, any total we publish is a **floor** — "at least
> N screens" — never an estimate of the whole install base. Instances that opt in are not a random
> sample of those that don't, so the number is not something to extrapolate from, and we won't.
## Running your own collector
Set `TELEMETRY_COLLECTOR=1` and this server accepts reports at `POST /api/telemetry/report`,
storing them in a `telemetry_reports` table keyed by `instance_id`. The endpoint is inert unless
that variable is set, so a normal install never exposes it.
Reports are upserted rather than appended — one row per install holding its latest report, not a
growing event log.

View file

@ -291,10 +291,24 @@ body {
50% { opacity: 0.4; }
}
.content {
/* Wraps the (optional) banners strip and main content so they stack
vertically as a single flex column, independent from the fixed sidebar.
Without this wrapper, #banners and .content would be direct siblings in
the (row-direction) body flexbox, turning the banner into a narrow flex
item next to the content instead of a full-width strip above it, and
shifting the whole dashboard layout out of alignment with the sidebar. */
.main-wrapper {
margin-left: var(--sidebar-width);
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
min-width: 0;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 24px 32px;
}
@ -398,6 +412,23 @@ body {
transform: translateY(-2px);
}
/* #238: a rotated display shown as its viewer sees it. The stage is the panel's face; the frame is
its framebuffer, turned back by the mount (js/lib/device-frame.js sizes and rotates it from the
players' shared rule). The frame is centred on its offset parent, so a stage without
`position: relative` would centre it on the page hence both halves live here together. */
.display-stage {
position: relative;
overflow: hidden;
}
.display-frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.device-card-preview {
aspect-ratio: 16/9;
background: var(--bg-primary);
@ -1487,7 +1518,8 @@ body {
}
.sidebar-backdrop.open { display: block; }
.nav-link { min-height: 44px; padding: 10px 14px; }
.content { margin-left: 0; padding: 16px; padding-top: 68px; }
.main-wrapper { margin-left: 0; }
.content { padding: 16px; padding-top: 68px; }
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.device-grid { grid-template-columns: 1fr; }
.content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }

View file

@ -81,7 +81,13 @@
<h2>Step 2: Run the ScreenTinker installer</h2>
<p>Open a terminal on the Pi and run:</p>
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | bash</code></pre>
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash</code></pre>
<p>The <code>sudo</code> is required — the installer writes systemd units, installs packages and
configures autostart. Without it the script stops on its first line and prints the correct
command, so nothing is half-installed.</p>
<p>That gives you <strong>All-in-One</strong>: the server and the player on the same Pi. To use
the Pi as a player only, pointing at a ScreenTinker server you already run:</p>
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://your-server</code></pre>
<p>The script will:</p>
<ul>
<li>Install Chromium (the kiosk browser used as the player)</li>

View file

@ -15,6 +15,11 @@
<link rel="stylesheet" href="/css/reset.css">
<link rel="stylesheet" href="/css/main.css">
<script src="/socket.io/socket.io.js"></script>
<!-- #238: the players' own rotation rule (server/lib/orientation-style.js), served under /player
because it IS the player's — the dashboard previews of rotated screens went sideways for as
long as this side derived its own geometry. Classic script: it publishes window.OrientationStyle
for both the player and the ES-module dashboard. -->
<script src="/player/orientation-style.js"></script>
<!-- OAuth providers loaded on-demand by login.js when needed -->
</head>
<body>
@ -169,9 +174,13 @@
</div>
</nav>
<main class="content" id="app">
<!-- Views rendered here -->
</main>
<div class="main-wrapper">
<div id="banners"></div>
<main class="content" id="app">
<!-- Views rendered here -->
</main>
</div>
<!-- Add Device Modal -->
<div class="modal-overlay" id="addDeviceModal" style="display:none">

View file

@ -210,6 +210,12 @@ export const api = {
// TOTP 2FA (#100) — opt-in per-user, local accounts only. See routes/auth.js.
totpStatus: () => request('/auth/totp/status'),
// Unlink an instance-wide SSO provider. The new password is required in the same call:
// the account must never sit between credentials.
ssoUnlink: (password) => request('/auth/oidc/unlink', { method: 'POST', body: JSON.stringify({ password }) }),
// Returns { url } to navigate to. Fetched rather than navigated to, because the session is
// a bearer token and a top-level navigation cannot carry one.
ssoLinkStart: (slug) => request(`/auth/oidc/${encodeURIComponent(slug)}/link/start`),
totpSetup: () => request('/auth/totp/setup', { method: 'POST' }),
totpEnable: (code) => request('/auth/totp/enable', { method: 'POST', body: JSON.stringify({ code }) }),
totpDisable: (code) => request('/auth/totp/disable', { method: 'POST', body: JSON.stringify({ code }) }),
@ -223,6 +229,7 @@ export const api = {
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
updateWorkspaceSecuritySettings: (workspaceId, data) => request(`/workspaces/${workspaceId}/security-settings`, { method: 'PUT', body: JSON.stringify(data) }),
// Workspace members + invites (slice 2A read-only)
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
@ -259,6 +266,10 @@ export const api = {
// #146: toggle the /api/status debug block exposure (platform-admin only).
adminGetStatusDebug: () => request('/admin/status-debug'),
adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }),
// Opt-in install statistics. GET returns { state, payload, last_report } — payload is the exact
// body that would be sent, so the UI can show it rather than describe it.
adminGetTelemetry: () => request('/admin/telemetry'),
adminSetTelemetry: (enabled) => request('/admin/telemetry', { method: 'PUT', body: JSON.stringify({ enabled }) }),
// Per-user workspace membership management (platform Users page modal).
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),

View file

@ -28,6 +28,7 @@ import { isPlatformAdmin } from './utils.js';
import { renderWorkspaceSwitcher } from './components/workspace-switcher.js';
import { showToast } from './components/toast.js';
import { api } from './api.js';
import { esc } from './utils.js';
const app = document.getElementById('app');
const sidebar = document.querySelector('.sidebar');
@ -248,7 +249,7 @@ async function refreshCurrentUser() {
// a redirect loop.
const hash = window.location.hash || '#/';
if (hasNoAccessibleWorkspace(fresh)
&& hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') {
&& hash !== '#/no-workspace' && !hash.startsWith('#/login') && hash !== '#/change-password') {
window.location.hash = '#/no-workspace';
}
} catch {}
@ -338,14 +339,29 @@ function route() {
// do nothing. The login view reads the token off the hash and shows the new-password form.
const isResetRoute = hash.startsWith('#/reset-password');
/*
* The SAME rule the comment above states, for the login route.
*
* The server finishes every single sign-on by redirecting to `#/login?sso=1` (claim the session)
* or `#/login?sso_error=<code>` (say what went wrong). Matching the hash EXACTLY meant neither
* survived: an unauthenticated browser the only kind that arrives here had the hash rewritten
* to a bare `#/login` and the query was gone before the login view ever ran. So a user who
* authenticated perfectly at their identity provider landed back on a clean login page, still
* signed out, with no message; and all sixteen error codes rendered SILENCE, which is worse than
* a wrong message because there is nothing to report or search for.
*
* It took the pre-existing `?verified=1` email-verification toast with it.
*/
const isLoginRoute = hash === '#/login' || hash.startsWith('#/login?');
// Auth check - redirect to login if not authenticated
if (!isAuthenticated() && hash !== '#/login' && !isResetRoute) {
if (!isAuthenticated() && !isLoginRoute && !isResetRoute) {
window.location.hash = '#/login';
return;
}
// If authenticated and on login page, redirect to dashboard or onboarding
if (isAuthenticated() && (hash === '#/login' || isResetRoute)) {
if (isAuthenticated() && (isLoginRoute || isResetRoute)) {
window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding';
return;
}
@ -422,8 +438,10 @@ function route() {
return;
}
// Login page (and password-reset links from email) - hide sidebar
if (hash === '#/login' || isResetRoute) {
// Login page (and password-reset links from email) - hide sidebar.
// Matches `#/login?...` too: the single sign-on return carries `?sso=1` / `?sso_error=<code>`,
// and an exact comparison meant the login view was never rendered for either.
if (isLoginRoute || isResetRoute) {
sidebar.style.display = 'none';
app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');
@ -556,6 +574,7 @@ function updateSidebarUser() {
const user = getCurrentUser();
if (!user) return;
updateVerifyBanner(user);
updateWidgetSandboxWarningBanner(user);
// Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin')
const adminNav = document.getElementById('adminNavItem');
@ -577,9 +596,9 @@ function updateSidebarUser() {
userEl.innerHTML = `
${user.avatar_url ? `<img src="${user.avatar_url}" style="width:28px;height:28px;border-radius:50%">` :
`<div style="width:28px;height:28px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:white">${(user.name || user.email)[0].toUpperCase()}</div>`}
`<div style="width:28px;height:28px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:white">${esc((user.name || user.email)[0].toUpperCase())}</div>`}
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div>
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(user.name || user.email)}</div>
<div style="font-size:10px;color:var(--text-muted)">${user.role}</div>
</div>
<button id="logoutBtn" class="btn-icon" title="${t('auth.sign_out')}" style="flex-shrink:0">
@ -608,8 +627,8 @@ function updateVerifyBanner(user) {
const unverified = user && user.email_verified === 0 && user.auth_provider === 'local';
if (!unverified) { if (existing) existing.remove(); return; }
if (existing) return;
const appEl = document.getElementById('app');
if (!appEl || !appEl.parentNode) return;
const bannersEl = document.getElementById('banners');
if (!bannersEl) return;
const b = document.createElement('div');
b.id = 'verifyBanner';
b.style.cssText = 'background:var(--warning,#f59e0b);color:#1a1200;padding:9px 16px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap';
@ -623,7 +642,29 @@ function updateVerifyBanner(user) {
catch { showToast(t('auth.verify_resend_failed'), 'error'); }
});
b.appendChild(btn);
appEl.parentNode.insertBefore(b, appEl);
bannersEl.appendChild(b);
}
function updateWidgetSandboxWarningBanner(user) {
const existing = document.getElementById('widgetSandboxWarningBanner');
const disabled = !!user?.current_organization?.widget_sandbox_isolation_disabled;
if (!disabled) { if (existing) existing.remove(); return; }
if (existing) return;
const bannersEl = document.getElementById('banners');
if (!bannersEl) return;
const b = document.createElement('div');
b.id = 'widgetSandboxWarningBanner';
b.style.cssText = 'background:var(--danger,#dc2626);color:#fff;padding:10px 16px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;font-weight:600';
const text = document.createElement('span');
text.style.whiteSpace = 'pre-line';
text.textContent = 'Widget sandbox isolation is DISABLED. Widget code in this organization runs\nwith full access to user sessions. Re-enable in Settings > Security.';
const link = document.createElement('a');
link.href = '#/settings';
link.textContent = 'Open Settings';
link.style.cssText = 'color:#fff;text-decoration:underline;font-weight:700';
b.appendChild(text);
b.appendChild(link);
bannersEl.appendChild(b);
}
// Initialize

View file

@ -1,3 +1,15 @@
/*
* Messages are ESCAPED. This builds innerHTML, and callers pass server error strings straight
* in including ones that reflect user input verbatim, such as the OIDC issuer in
* `not a URL: <value>`. A review typed `<img src=x onerror=alert(1)>` as an issuer and got script
* execution in the admin's own session. A toast is a place text goes, never markup.
*/
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
export function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
@ -10,7 +22,7 @@ export function showToast(message, type = 'info', duration = 4000) {
type === 'error' ? '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>' :
'<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>'}
</svg>
<span>${message}</span>
<span>${esc(message)}</span>
`;
container.appendChild(toast);
setTimeout(() => {

View file

@ -38,7 +38,6 @@ export function openTypeToConfirmModal(opts = {}) {
const input = overlay.querySelector('#ttcInput');
const confirmBtn = overlay.querySelector('#ttcConfirm');
const errorEl = overlay.querySelector('#ttcError');
input.focus();
const matches = () => input.value.trim() === String(expected);
input.addEventListener('input', () => { confirmBtn.disabled = !matches(); });

View file

@ -260,6 +260,7 @@ export default {
'device.info.status': 'Status',
'device.info.ip_address': 'IP-Adresse',
'device.info.local_ip': 'Lokale IP',
'device.info.local_ip6': 'Lokale IPv6',
'device.info.wifi_needs_location': 'Standortberechtigung erforderlich',
'device.info.battery': 'Akku',
'device.info.storage': 'Speicher',
@ -347,6 +348,9 @@ export default {
'device.assign.select_first': 'Erst etwas auswählen',
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
'device.toast.screenshot_requested': 'Screenshot angefordert',
'device.toast.screenshot_unsupported': 'Der Player dieses Displays unterstützt keine Screenshots',
'device.toast.screenshot_offline': 'Display ist offline — Screenshot nicht angefordert',
'device.toast.screenshot_failed': 'Screenshot-Anfrage fehlgeschlagen — keine Antwort vom Server',
'device.toast.renamed': 'Bildschirm umbenannt',
'device.toast.removing': 'Wird entfernt...',
'device.toast.removed': 'Bildschirm entfernt',
@ -763,6 +767,10 @@ export default {
'playlist.draft.discard_changes': 'Änderungen verwerfen',
'playlist.draft.publish': 'Veröffentlichen',
'playlist.draft.publishing': 'Wird veröffentlicht...',
'playlist.preview_prev': 'Zurück',
'playlist.preview_next': 'Weiter',
'playlist.preview_position': '{current} von {total}',
'playlist.preview_zoned': 'Zonen laufen gleichzeitig',
'playlist.toast.created': 'Playlist erstellt',
'playlist.toast.deleted': 'Playlist gelöscht',
'playlist.toast.published': 'Playlist veröffentlicht — Geräte aktualisiert',

View file

@ -116,6 +116,8 @@ export default {
'common.unknown': 'Unknown',
// Auth (login view)
'auth.next': 'Next',
'auth.error_email_required': 'Enter your email address',
'auth.sign_in': 'Sign In',
'auth.sign_out': 'Sign out',
'auth.create_account': 'Create Account',
@ -132,6 +134,93 @@ export default {
'auth.trial_notice': 'New accounts get a 14-day free Pro trial',
'auth.divider_or': 'OR',
'auth.signin_google': 'Sign in with Google',
'auth.signin_with': 'Continue with {provider}',
'auth.sso_failed': 'Single sign-on failed. Please try again.',
'auth.sso_org_hint': 'Your organization uses single sign-on.',
'auth.signin_sso': 'Continue with single sign-on',
'sso.title': 'Single sign-on',
'sso.blurb': 'Let your team sign in with your own identity provider. Anyone using an email address at one of your domains will be sent there instead of being asked for a password.',
'sso.add': 'Add a provider',
'sso.none': 'No provider configured yet.',
'sso.create': 'Add provider',
'sso.saved': 'Saved',
'sso.save_failed': 'Could not save that provider.',
'sso.load_failed': 'Could not load single sign-on settings.',
'sso.missing_fields': 'Name, issuer and client ID are required.',
'sso.confirm_delete': 'Remove this provider? Anyone who signs in with it will lose that route.',
'sso.delete': 'Remove',
'sso.enable': 'Enable',
'sso.disable': 'Disable',
'sso.disabled': 'disabled',
'sso.domains_label': 'Email domains',
'sso.domains_heading': 'Sign-in domains',
'sso.removed': 'Removed.',
'sso.only_stranded': 'Single sign-on is now required.\n\nThese members are not at a verified domain, so they can no longer sign in at all:\n\n{list}\n\nVerify their domain, or remove them from this organization.',
'sso.only_heading': 'Require single sign-on',
'sso.only_help': 'When required, people at your verified domains can only sign in through your identity provider — a password will not work. Your provider keeps control of MFA and of removing access.',
'sso.only_on': 'Single sign-on is required for your verified domains.',
'sso.only_off': 'Password sign-in is still allowed alongside single sign-on.',
'sso.only_enable': 'Require single sign-on',
'sso.only_confirm': 'Require single sign-on for everyone at your verified domains?\n\nPasswords will stop working for them at their next sign-in; sessions already open continue until they expire. Turning this back off needs approval from the people who run this server, so make sure your identity provider is working first.',
'sso.only_needs_domain': 'Verify a sign-in domain first — otherwise nobody would be able to sign in.',
'sso.only_remove_help': 'Turning this off re-opens password sign-in, so it needs approval from the people who run this server.',
'sso.only_request': 'Request to stop requiring single sign-on',
'sso.only_reason_prompt': 'Why do you need password sign-in re-opened? (optional, but it helps the reviewer)',
'sso.only_requested': 'Request sent. Single sign-on stays required until it is approved.',
'sso.only_pending': 'A request to stop requiring single sign-on is awaiting approval. Nothing changes until then.',
'sso.only_cancel': 'Withdraw request',
'sso.only_cancelled': 'Request withdrawn.',
'sso.only_failed': 'That did not work.',
'sso.domain_verified': 'verified',
'sso.domain_pending': 'not verified — routes nobody yet',
'sso.unverified_warning': 'Some domains are not verified yet, so nobody is routed to this provider by email address.',
'sso.verify_now': 'Verify',
'sso.verifying': 'Checking DNS…',
'sso.verify_failed': 'Could not verify that domain.',
'sso.domain_verified_toast': '{domain} is verified.',
'sso.dns_instructions': 'Publish this TXT record in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.',
'sso.callback_label': 'Redirect URI — add this to your provider',
'sso.f_name': 'Display name',
'sso.f_issuer': 'Issuer URL',
'sso.f_issuer_hint': 'The base URL whose /.well-known/openid-configuration describes your provider. We check it before saving.',
'sso.f_client_id': 'Client ID',
'sso.f_client_secret': 'Client secret (optional)',
'sso.f_client_secret_hint': 'Leave blank for a public client — we use PKCE, so a secret is not required. Stored encrypted and never shown again.',
'sso.f_domains': 'Email domains',
'sso.f_domains_hint': 'Comma separated. Anyone with an address at these domains is sent to this provider.',
'sso.edit': 'Edit',
'sso.save': 'Save changes',
'sso.cancel': 'Cancel',
'sso.secret_set': 'A secret is set — leave blank to keep it',
'sso.secret_none': 'No secret set (public client)',
'sso.secret_edit_hint': 'Leave blank to keep the current secret. Type a new one to replace it.',
'sso.secret_clear': 'Remove the stored secret (use a public client)',
'sso.test': 'Test',
'sso.testing': 'Checking the provider…',
'sso.test_failed': 'Could not reach that provider.',
'sso.check_discovery': 'OpenID configuration',
'sso.check_endpoints': 'Authorization and token endpoints',
'sso.check_signing_keys': 'Signing keys',
'sso.test_caveat': 'This confirms the provider is reachable and its tokens can be verified. It cannot check the client ID, the secret, or that the redirect URI is registered — only a real sign-in does that.',
'auth.sso_err_expired': 'That sign-in took too long. Please try again.',
'auth.sso_err_bad_state': 'Sign-in could not be verified. Please start again.',
'auth.sso_err_no_code': 'The provider did not return an authorization code.',
'auth.sso_err_no_email': 'Your provider did not share an email address.',
'auth.sso_err_email_unverified': 'Your provider has not verified that email address.',
'auth.sso_err_verification_failed': 'We could not verify the sign-in with your provider.',
'auth.sso_err_provider_refused': 'Your provider declined the sign-in.',
'auth.sso_err_provider_unavailable': 'That provider is not reachable right now.',
'auth.sso_err_unknown_provider': 'That sign-in provider is not configured.',
'auth.sso_err_registration_disabled': 'New accounts are disabled on this instance.',
'auth.sso_err_account_exists_local': 'An account with this email already exists and uses a password. Sign in with your password instead.',
'auth.sso_err_subject_mismatch': 'This email is already linked to a different account at your provider.',
'auth.sso_err_server_error': 'Something went wrong completing sign-in.',
// Both of these used to fall through to "please try again", which is advice that can never work:
// retrying is exactly what will not help, and the user needs to be told who to talk to instead.
'auth.sso_required': 'Your organization requires single sign-on. Use \u201cContinue with single sign-on\u201d above \u2014 your password will not work here.',
'auth.sso_err_sso_required': 'Your organization requires its own single sign-on. Use the single sign-on option for your organization.',
'auth.sso_err_domain_not_allowed': 'Your organization has not verified that email domain for sign-in. Ask your administrator to verify it in ScreenTinker.',
'auth.sso_err_account_exists_other_provider': 'An account with this email already exists and signs in through a different provider. Use that provider, or ask your administrator.',
'auth.signin_microsoft': 'Sign in with Microsoft',
'auth.back_to_signin': 'Back to Sign In',
// TOTP 2FA challenge (second login step)
@ -403,6 +492,9 @@ export default {
'device.confirm_discard_draft': 'Discard all unpublished changes and revert to the last published version?',
'device.failed_load': 'Failed to load device',
'device.no_screenshot': 'No screenshot available. Click "Screenshot" to capture one.',
// Shown instead of the line above on a player that cannot capture its own screen — pointing at
// a "Screenshot" button that is correctly not rendered reads as a broken dashboard.
'device.no_screenshot_unsupported': 'This player cannot capture its own screen.',
'device.no_content_assigned': 'No content assigned',
'device.now_playing_id': 'Playing: {id}',
'device.playlist_count_one': '1 item in playlist',
@ -473,6 +565,7 @@ export default {
'device.info.status': 'Status',
'device.info.ip_address': 'IP Address',
'device.info.local_ip': 'Local IP',
'device.info.local_ip6': 'Local IPv6',
'device.info.wifi_needs_location': 'Needs location permission',
'device.info.battery': 'Battery',
'device.info.storage': 'Storage',
@ -484,6 +577,8 @@ export default {
'device.info.os_version': 'OS Version',
'device.info.serial': 'Serial',
'device.info.temperature': 'Temperature',
'device.info.attached_display': 'Attached display',
'device.info.video_mode': 'Video mode',
'device.info.output_n': '(output {n})',
'device.info.player_type': 'Player Type',
'device.info.web_player': 'Web Player',
@ -545,7 +640,16 @@ export default {
'device.form.notes_label': 'Notes',
'device.form.notes_placeholder': 'Location, setup details, etc.',
'device.debug.toggle': 'Debug logging (live)',
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
'device.debug.hint': 'Streams this display\'s log in real time, and replays what it buffered before you opened it. Turns off when you leave this screen, and on the device itself after 30 minutes.',
'device.debug.freeze': 'Freeze',
'device.debug.resume': 'Resume',
'device.debug.copy': 'Copy',
'device.debug.clear': 'Clear',
'device.debug.held': 'frozen — {n} new line(s) waiting',
'device.debug.held_max': 'frozen — {n} waiting (oldest now being dropped)',
'device.debug.copied': 'Copied {n} line(s) to the clipboard',
'device.debug.copy_empty': 'Nothing to copy yet',
'device.debug.copy_failed': 'Could not reach the clipboard — select the log and copy manually',
'device.ota.toggle': 'Self-update (OTA)',
'device.ota.beta': 'Accept pre-release builds',
'device.ota.beta_hint': 'Puts this display on the pre-release channel: it receives the beta build if the server has one published, and keeps a test build instead of being updated back to the current release. Untick to move it back to the release build. Does nothing if no beta is published.',
@ -579,6 +683,8 @@ export default {
'device.ctl.screen_on': 'Screen On',
'device.ctl.launch_player': 'Launch Player',
'device.ctl.force_update': 'Force Update',
'device.ctl.clear_update_cache': 'Clear Update Cache',
'device.ctl.clear_update_cache_tip': 'Delete any update file this display has already downloaded, so the next check fetches a fresh copy. Use if updates keep failing.',
'device.ctl.shutdown': 'Shutdown',
// Remote tab
'device.remote.start_prompt': 'Click "Start Remote" to begin',
@ -645,6 +751,9 @@ export default {
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
// Toasts
'device.toast.screenshot_requested': 'Screenshot requested',
'device.toast.screenshot_unsupported': 'This display\'s player can\'t take screenshots',
'device.toast.screenshot_offline': 'Display is offline — screenshot not requested',
'device.toast.screenshot_failed': 'Screenshot request failed — no response from server',
'device.toast.renamed': 'Display renamed',
'device.toast.removing': 'Removing...',
'device.toast.removed': 'Display removed',
@ -666,6 +775,7 @@ export default {
'device.toast.screen_on_sent': 'Screen on command sent',
'device.toast.launch_sent': 'Launch command sent',
'device.toast.update_triggered': 'Update check triggered',
'device.toast.update_cache_cleared': 'Update cache cleared — the next check will download afresh',
'device.toast.remote_started': 'Remote session started',
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
@ -731,6 +841,33 @@ export default {
'settings.save_profile': 'Save Profile',
'settings.email_alerts': 'Email me when devices go offline',
'settings.change_password': 'Change Password',
// Sign-in method (#258). The link warning is deliberately explicit about destruction of the
// password — that is the part users miss, and it is not reversible without setting a new one.
'settings.signin_method': 'Sign-in method',
'settings.signin_password_now': 'This account signs in with a password. You can link it to a single sign-on provider instead.',
'settings.signin_password_only': 'This account signs in with a password. No single sign-on providers are configured on this server.',
'settings.signin_link': 'Link {provider}',
'settings.signin_link_warning': 'You are linking this account to {provider}.\n\nYour local password will be DELETED. After this you sign in with {provider} only.\n\nTo go back to a password later, unlink {provider} and set a new one.',
'settings.signin_linked': 'This account signs in with {provider}. It has no password.',
'settings.signin_unlink': 'Unlink {provider}',
'settings.signin_unlink_desc': 'Set a password to sign in with instead. It takes effect immediately and {provider} is unlinked in the same step.',
'settings.signin_unlink_confirm': 'Set password and unlink',
'settings.signin_unlinked_toast': 'Unlinked. You now sign in with your password.',
'settings.passwords_dont_match': 'The two passwords do not match',
'settings.signin_linked_toast': 'Linked. You now sign in with {provider}, and your password has been removed.',
'settings.signin_err_link_email_mismatch': 'That provider account uses a different email address than this account. Sign in to the provider with the same address and try again.',
'settings.signin_err_link_already_used': 'That provider account is already linked to a different ScreenTinker account.',
'settings.signin_err_not_linkable': 'Only the providers configured on this server can be linked to an account.',
'settings.signin_err_no_email': 'The provider did not supply an email address, so the account could not be linked.',
'settings.signin_err_email_unverified': 'The provider would not confirm that email address is verified.',
'settings.signin_err_verification_failed': 'The sign-in could not be verified. Nothing was changed.',
'settings.signin_err_provider_unavailable': 'The provider could not be reached. Nothing was changed.',
'settings.signin_err_provider_refused': 'The provider refused the request. Nothing was changed.',
'settings.signin_err_unknown_provider': 'That provider is not configured on this server.',
'settings.signin_err_expired': 'That took too long. Start the link again.',
'settings.signin_err_bad_state': 'The response did not match the request. Start the link again.',
'settings.signin_err_no_code': 'The provider returned no authorization code. Start the link again.',
'settings.signin_err_server_error': 'Something went wrong. Nothing was changed.',
'settings.password_min_8': 'Must be at least 8 characters.',
'settings.current_password': 'Current Password',
'settings.new_password': 'New Password',
@ -1159,6 +1296,10 @@ export default {
'playlist.zones_count_one': '1 zone',
'playlist.zones_count_other': '{n} zones',
'playlist.layout_ambiguous': 'Items reference zones from more than one layout',
'playlist.preview_prev': 'Previous',
'playlist.preview_next': 'Next',
'playlist.preview_position': '{current} of {total}',
'playlist.preview_zoned': 'Zones play together',
'playlist.back': 'Back',
'playlist.items_empty': 'This playlist is empty',
'playlist.items_empty_hint': 'Click "Add Content" to add items.',
@ -1306,6 +1447,16 @@ export default {
'admin.orgs.ws_deleted': 'Workspace "{name}" deleted',
'admin.access_denied': 'Access Denied',
'admin.access_denied_desc': 'Platform admin access required.',
'admin.sso_only.title': 'Single sign-on removal requests',
'admin.sso_only.desc': 'An organization has asked to stop requiring its identity provider. Until you approve, nothing changes for them.',
'admin.sso_only.requested_by': 'Requested by {who}',
'admin.sso_only.effect': 'Approving re-opens password sign-in for everyone at this organization\u2019s verified domains.',
'admin.sso_only.approve': 'Approve removal',
'admin.sso_only.reject': 'Reject',
'admin.sso_only.confirm': 'Re-open password sign-in for this organization?\n\nTheir identity provider will no longer be the only way in. Approve only if you are satisfied the request is genuine.',
'admin.sso_only.approved': 'Approved. Password sign-in is re-opened for that organization.',
'admin.sso_only.rejected': 'Rejected. Single sign-on is still required.',
'admin.sso_only.failed': 'That did not work.',
'admin.all_users': 'All Users',
'admin.plans': 'Subscription Plans',
'admin.col.accounts': 'Accounts',

View file

@ -290,6 +290,7 @@ export default {
'device.info.status': 'Estado',
'device.info.ip_address': 'Dirección IP',
'device.info.local_ip': 'IP local',
'device.info.local_ip6': 'IPv6 local',
'device.info.wifi_needs_location': 'Requiere permiso de ubicación',
'device.info.battery': 'Batería',
'device.info.storage': 'Almacenamiento',
@ -377,6 +378,9 @@ export default {
'device.assign.select_first': 'Primero selecciona algo',
'device.assign.kiosk_widget_name': 'Kiosco: {name}',
'device.toast.screenshot_requested': 'Captura solicitada',
'device.toast.screenshot_unsupported': 'El reproductor de esta pantalla no admite capturas de pantalla',
'device.toast.screenshot_offline': 'La pantalla está desconectada — captura no solicitada',
'device.toast.screenshot_failed': 'La solicitud de captura falló — sin respuesta del servidor',
'device.toast.renamed': 'Pantalla renombrada',
'device.toast.removing': 'Eliminando...',
'device.toast.removed': 'Pantalla eliminada',
@ -793,6 +797,10 @@ export default {
'playlist.draft.discard_changes': 'Descartar cambios',
'playlist.draft.publish': 'Publicar',
'playlist.draft.publishing': 'Publicando...',
'playlist.preview_prev': 'Anterior',
'playlist.preview_next': 'Siguiente',
'playlist.preview_position': '{current} de {total}',
'playlist.preview_zoned': 'Las zonas se reproducen juntas',
'playlist.toast.created': 'Lista creada',
'playlist.toast.deleted': 'Lista eliminada',
'playlist.toast.published': 'Lista publicada — dispositivos actualizados',

View file

@ -260,6 +260,7 @@ export default {
'device.info.status': 'Statut',
'device.info.ip_address': 'Adresse IP',
'device.info.local_ip': 'IP locale',
'device.info.local_ip6': 'IPv6 locale',
'device.info.wifi_needs_location': 'Autorisation de localisation requise',
'device.info.battery': 'Batterie',
'device.info.storage': 'Stockage',
@ -347,6 +348,9 @@ export default {
'device.assign.select_first': 'Sélectionnez d\'abord un élément',
'device.assign.kiosk_widget_name': 'Kiosque : {name}',
'device.toast.screenshot_requested': 'Capture demandée',
'device.toast.screenshot_unsupported': 'Le lecteur de cet écran ne prend pas en charge les captures d\'écran',
'device.toast.screenshot_offline': 'Écran hors ligne — capture non demandée',
'device.toast.screenshot_failed': 'Échec de la demande de capture — pas de réponse du serveur',
'device.toast.renamed': 'Écran renommé',
'device.toast.removing': 'Suppression...',
'device.toast.removed': 'Écran retiré',
@ -763,6 +767,10 @@ export default {
'playlist.draft.discard_changes': 'Annuler les modifications',
'playlist.draft.publish': 'Publier',
'playlist.draft.publishing': 'Publication...',
'playlist.preview_prev': 'Précédent',
'playlist.preview_next': 'Suivant',
'playlist.preview_position': '{current} sur {total}',
'playlist.preview_zoned': 'Les zones jouent ensemble',
'playlist.toast.created': 'Liste créée',
'playlist.toast.deleted': 'Liste supprimée',
'playlist.toast.published': 'Liste publiée — appareils mis à jour',

View file

@ -276,6 +276,7 @@ export default {
'device.info.status': 'Stato',
'device.info.ip_address': 'Indirizzo IP',
'device.info.local_ip': 'IP locale',
'device.info.local_ip6': 'IPv6 locale',
'device.info.wifi_needs_location': 'Richiede permesso di posizione',
'device.info.battery': 'Batteria',
'device.info.storage': 'Archiviazione',
@ -371,6 +372,9 @@ export default {
'device.assign.kiosk_widget_name': 'Chiosco: {name}',
// Toasts
'device.toast.screenshot_requested': 'Richiesta screenshot inviata',
'device.toast.screenshot_unsupported': 'Il player di questo schermo non supporta gli screenshot',
'device.toast.screenshot_offline': 'Lo schermo è offline — screenshot non richiesto',
'device.toast.screenshot_failed': 'Richiesta screenshot non riuscita — nessuna risposta dal server',
'device.toast.renamed': 'Schermo rinominato',
'device.toast.removing': 'Rimozione in corso...',
'device.toast.removed': 'Schermo rimosso',
@ -751,6 +755,10 @@ export default {
'playlist.draft.discard_changes': 'Scarta Modifiche',
'playlist.draft.publish': 'Pubblica',
'playlist.draft.publishing': 'Pubblicazione...',
'playlist.preview_prev': 'Precedente',
'playlist.preview_next': 'Successivo',
'playlist.preview_position': '{current} di {total}',
'playlist.preview_zoned': 'Le zone vengono riprodotte insieme',
'playlist.toast.created': 'Playlist creata',
'playlist.toast.deleted': 'Playlist eliminata',
'playlist.toast.published': 'Playlist pubblicata — dispositivi aggiornati',

View file

@ -260,6 +260,7 @@ export default {
'device.info.status': 'Status',
'device.info.ip_address': 'Endereço IP',
'device.info.local_ip': 'IP local',
'device.info.local_ip6': 'IPv6 local',
'device.info.wifi_needs_location': 'Requer permissão de localização',
'device.info.battery': 'Bateria',
'device.info.storage': 'Armazenamento',
@ -347,6 +348,9 @@ export default {
'device.assign.select_first': 'Selecione algo primeiro',
'device.assign.kiosk_widget_name': 'Quiosque: {name}',
'device.toast.screenshot_requested': 'Captura solicitada',
'device.toast.screenshot_unsupported': 'O player desta tela não suporta capturas de tela',
'device.toast.screenshot_offline': 'A tela está offline — captura não solicitada',
'device.toast.screenshot_failed': 'Falha na solicitação de captura — sem resposta do servidor',
'device.toast.renamed': 'Tela renomeada',
'device.toast.removing': 'Removendo...',
'device.toast.removed': 'Tela removida',
@ -763,6 +767,10 @@ export default {
'playlist.draft.discard_changes': 'Descartar alterações',
'playlist.draft.publish': 'Publicar',
'playlist.draft.publishing': 'Publicando...',
'playlist.preview_prev': 'Anterior',
'playlist.preview_next': 'Próximo',
'playlist.preview_position': '{current} de {total}',
'playlist.preview_zoned': 'As zonas tocam juntas',
'playlist.toast.created': 'Playlist criada',
'playlist.toast.deleted': 'Playlist excluída',
'playlist.toast.published': 'Playlist publicada — dispositivos atualizados',

View file

@ -0,0 +1,76 @@
// #238: show a device's output the way a person standing in front of the panel sees it.
//
// Everywhere the dashboard showed a rotated display — the device preview modal, the Now Playing
// screenshot, the device cards — it showed the framebuffer as captured/rendered, i.e. sideways,
// while the panel on the wall was right. Designers use these surfaces to check their work, so a
// sideways preview turned every anomaly on a portrait screen into "is that real?".
//
// The geometry itself is NOT here: it is the same rule the players rotate by
// (server/lib/orientation-style.js, loaded as window.OrientationStyle), because a second copy of a
// rotation rule is precisely how the dashboard and the panel came to disagree in the first place.
// This file only measures the box and applies the answer.
// stage element -> { inner, orientation }. Weak so a re-rendered dashboard doesn't pin dead nodes.
const framed = new WeakMap();
let observer = null;
// Sizes are in px, so they are wrong the moment the stage resizes — and a stage inside an inactive
// tab measures 0x0 until it is shown, which is the common case for Now Playing. One observer for
// every stage: the callback re-measures whatever actually changed, including 0 -> visible.
function ensureObserver() {
if (observer || typeof ResizeObserver === 'undefined') return observer;
observer = new ResizeObserver((entries) => { entries.forEach(e => applyFrame(e.target)); });
return observer;
}
function applyFrame(stage) {
const entry = framed.get(stage);
if (!entry) return;
if (!stage.isConnected) { // modal closed / list re-rendered
framed.delete(stage);
if (observer) observer.unobserve(stage);
return;
}
const OS = typeof window !== 'undefined' && window.OrientationStyle;
if (!OS || !OS.previewFrameStyle) return; // shared rule failed to load: leave today's rendering alone
const st = OS.previewFrameStyle(entry.orientation, { width: stage.clientWidth, height: stage.clientHeight });
const el = entry.inner;
if (!el) return;
el.style.width = st.width;
el.style.height = st.height;
el.style.top = st.top;
el.style.left = st.left;
el.style.transform = st.transform;
el.style.transformOrigin = st.transformOrigin;
// A rotated screenshot has to be letterboxed rather than cropped: the card's `object-fit: cover`
// applied to a frame whose axes are swapped fills the box by discarding most of the picture —
// a "preview" of the middle 30% of the screen.
el.style.objectFit = (st.transform && OS.swapsAxes(entry.orientation)) ? 'contain' : '';
}
/**
* Present `inner` (an iframe of the player, or a screenshot img) inside `stage` as the panel's
* face. Safe to call repeatedly screenshot handlers replace the img element, and re-registering
* is how the new one gets framed.
*
* @param {Element} stage fixed box in the dashboard, sized for the AS-DISPLAYED aspect
* @param {Element} inner the device's output; positioned and rotated inside the stage
* @param {string} orientation the device row's orientation
*/
export function frameDeviceOutput(stage, inner, orientation) {
if (!stage || !inner) return;
// Applied here rather than left to each call site: the frame is absolutely positioned and centred
// on its offset parent, so a stage that forgets `position: relative` centres it on the PAGE.
stage.classList.add('display-stage');
inner.classList.add('display-frame');
framed.set(stage, { inner, orientation: orientation || 'landscape' });
applyFrame(stage);
const ro = ensureObserver();
if (ro) { ro.unobserve(stage); ro.observe(stage); }
}
/** Stage aspect for a device, as the viewer sees it ('9 / 16' for a portrait-hung 16:9 panel). */
export function displayAspectRatio(orientation) {
const OS = typeof window !== 'undefined' && window.OrientationStyle;
return OS && OS.previewAspectRatio ? OS.previewAspectRatio(orientation) : '16 / 9';
}

View file

@ -112,9 +112,22 @@ function emit(event, data) {
if (cbs) cbs.forEach(cb => cb(data));
}
export function requestScreenshot(deviceId) {
// Optional callback receives the server-side ack: { delivered, reason, capability }.
// reason is 'offline' (no live connection) or 'unsupported' (this player type can't
// take screenshots). Callers without a callback keep firing-and-forgetting — the
// dashboard grid and the device-detail 5s poll stay silent; only the explicit
// Screenshot button asks for the verdict.
export function requestScreenshot(deviceId, callback) {
console.log('requestScreenshot:', deviceId, 'socket connected:', dashboardSocket?.connected);
if (dashboardSocket) dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId });
if (!dashboardSocket) return;
if (typeof callback === 'function') {
dashboardSocket.timeout(5000).emit('dashboard:request-screenshot', { device_id: deviceId }, (err, ack) => {
if (err) callback({ delivered: false, reason: 'no_ack' });
else callback(ack || { delivered: false, reason: 'no_ack' });
});
} else {
dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId });
}
}
export function startRemote(deviceId) {

View file

@ -79,6 +79,15 @@ export async function render(container) {
</div>
</div>
<!-- Single sign-on removal approvals. First, because it is the only screen on this page an
operator is DIRECTED to by an email, and because a tenant is locked out of their own
product while it sits here. -->
<div class="settings-section" id="ssoOnlySection" style="display:none">
<h3>${t('admin.sso_only.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.sso_only.desc')}</p>
<div id="ssoOnlyRequests"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.all_users')}</h3>
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
@ -137,6 +146,7 @@ export async function render(container) {
loadUsers();
loadOrgs();
loadSsoOnlyRequests();
loadBranding();
loadPlans();
loadSystem();
@ -146,6 +156,70 @@ export async function render(container) {
// #36: list organizations with owner + resource counts; platform admin can
// cascade-delete an org or an individual workspace (type-the-name confirm).
/*
* Pending "stop requiring single sign-on" requests.
*
* The notification email tells the operator to review this under Admin, and for a while it did not
* exist the only way to approve was curl, while the customer sat locked out. The section hides
* itself when there is nothing pending so it is never noise.
*/
async function loadSsoOnlyRequests() {
const section = document.getElementById('ssoOnlySection');
const host = document.getElementById('ssoOnlyRequests');
if (!section || !host) return;
// NB: `api` is a map of named methods, not a generic client — there is no api.get(), and calling
// one silently hid this whole section behind the catch below.
const authed = (path, init = {}) => fetch(`/api${path}`, {
...init,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...(init.headers || {}) },
});
let requests = [];
try {
const res = await authed('/organizations/sso-only/removal-requests');
if (!res.ok) throw new Error(String(res.status));
requests = (await res.json()).requests || [];
} catch {
section.style.display = 'none';
return;
}
// Clear as well as hide: leaving the last decided request in the tree kept its live
// Approve/Reject listeners attached to a request that no longer exists.
if (!requests.length) { host.innerHTML = ''; section.style.display = 'none'; return; }
section.style.display = '';
host.innerHTML = requests.map((r) => `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
<div><strong>${esc(r.organization_name || r.organization_id)}</strong></div>
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">
${esc(t('admin.sso_only.requested_by', { who: r.requested_by_email || 'unknown' }))}
</div>
${r.reason ? `<div style="font-size:12px;margin-top:6px">${esc(r.reason)}</div>` : ''}
<div style="font-size:12px;color:var(--warning,#b45309);margin-top:8px">${esc(t('admin.sso_only.effect'))}</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
<button class="btn btn-danger btn-sm" data-sso-approve="${esc(r.id)}">${esc(t('admin.sso_only.approve'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-reject="${esc(r.id)}">${esc(t('admin.sso_only.reject'))}</button>
</div>
</div>`).join('');
const decide = async (id, decision) => {
try {
const res = await authed(`/organizations/sso-only/removal-requests/${id}/${decision}`, { method: 'POST', body: '{}' });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))).error) || String(res.status));
showToast(t(decision === 'approve' ? 'admin.sso_only.approved' : 'admin.sso_only.rejected'), 'success');
await loadSsoOnlyRequests();
} catch (e) {
showToast((e && e.message) || t('admin.sso_only.failed'), 'error');
}
};
// Approving RE-OPENS password sign-in for a whole organization, so it is confirmed; rejecting
// only leaves the safe state in place and is not.
host.querySelectorAll('[data-sso-approve]').forEach((b) => b.addEventListener('click', () => {
if (window.confirm(t('admin.sso_only.confirm'))) decide(b.dataset.ssoApprove, 'approve');
}));
host.querySelectorAll('[data-sso-reject]').forEach((b) => b.addEventListener('click', () => decide(b.dataset.ssoReject, 'reject')));
}
async function loadOrgs() {
const el = document.getElementById('orgsTable');
if (!el) return;
@ -276,22 +350,28 @@ async function loadUsers() {
<tbody>
${users.map(u => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px"><div style="font-weight:500">${u.name || u.email}</div><div style="font-size:11px;color:var(--text-muted)">${u.email}</div></td>
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
<!-- ESCAPED: these come from self-registration and from an identity provider's
email claim, so they are attacker-chosen. A reviewer registered an address whose
local part was an img tag with an onerror handler, anonymously, and got script
execution in the PLATFORM ADMIN's session on this page - the very page operators
are now emailed to. Note backticks are illegal here: this sits inside a template
literal. -->
<td style="padding:8px"><div style="font-weight:500">${esc(u.name || u.email)}</div><div style="font-size:11px;color:var(--text-muted)">${esc(u.email)}</div></td>
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${esc(u.auth_provider)}</span></td>
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}</td>
<td style="padding:8px">
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${esc(u.id)}">
${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')}
</select>
</td>
<td style="padding:8px">
<select class="input" style="max-width:130px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}">
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${esc(p.display_name)}</option>`).join('')}
</select>
</td>
${workspaceCell(u)}
<td style="padding:8px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${esc(u.id)}" data-user-email="${esc(u.email)}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
</td>
</tr>

View file

@ -26,7 +26,7 @@ export async function render(container) {
<div class="settings-section">
<h3>${t('billing.current_plan')}</h3>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
<div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div>
<div style="font-size:28px;font-weight:700;color:var(--accent)">${esc(subData.plan.display_name)}</div>
${subData.self_hosted ? `<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.self_hosted')}</span>` : ''}
${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.trial_days_left', { n: subData.trial.days_left })}</span>` : ''}
</div>
@ -75,7 +75,7 @@ export async function render(container) {
${plans.map(p => `
<div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative">
${p.id === subData.plan.id ? `<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">${t('billing.current')}</div>` : ''}
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div>
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${esc(p.display_name)}</div>
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">${t('billing.per_month')}</span>` : t('billing.free')}
</div>

View file

@ -430,7 +430,7 @@ async function loadContent() {
grid.innerHTML = content.map(c => {
const exp = expiryInfo(c);
return `
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}" style="position:relative;${state.selected.has(c.id) ? 'outline:2px solid var(--primary,#3B82F6);outline-offset:-2px;' : ''}${exp.expired ? 'opacity:.55' : ''}">
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${esc(c.folder || '')}" style="position:relative;${state.selected.has(c.id) ? 'outline:2px solid var(--primary,#3B82F6);outline-offset:-2px;' : ''}${exp.expired ? 'opacity:.55' : ''}">
<label class="content-select-wrap" style="position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);border-radius:4px;padding:3px;display:flex;cursor:pointer">
<input type="checkbox" class="content-select" data-content-id="${c.id}" ${state.selected.has(c.id) ? 'checked' : ''} style="width:16px;height:16px;margin:0;cursor:pointer">
</label>

View file

@ -1,10 +1,11 @@
import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc, livenessBadge } from '../utils.js';
import { esc, livenessBadge, isPlatformAdmin } from '../utils.js';
import { t, tn } from '../i18n.js';
import * as gettingStarted from '../components/getting-started.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
import { frameDeviceOutput } from '../lib/device-frame.js';
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
// Command types only — labels resolved through t('dashboard.cmd.<type>')
@ -78,6 +79,22 @@ function renderProgressFor(deviceId) {
});
}
// #238: a screenshot is the panel's raw framebuffer, so a device set to 90/270 sends a landscape
// image with the content lying on its side — the wall mount is what turns it upright, and the card
// had no stand-in for the mount. Every portrait screen in the fleet therefore looked wrong at a
// glance on the one screen people scan to check the fleet is fine.
//
// Re-run after any render that replaces card markup; the orientation rides on the card so the
// socket handler can re-frame a single card without re-reading the device list.
function frameCard(stage) {
const img = stage && stage.querySelector('img');
if (img) frameDeviceOutput(stage, img, stage.dataset.orientation);
}
function frameCardScreenshots(root) {
(root || document).querySelectorAll('.device-card-preview[data-orientation]').forEach(frameCard);
}
function renderDeviceCard(device) {
const token = localStorage.getItem('token');
const screenshotUrl = device.screenshot_path
@ -85,12 +102,16 @@ function renderDeviceCard(device) {
: null;
const checked = selectedDeviceIds.has(device.id);
// A panel that cannot capture its own screen is not asked to, every 30 seconds, forever. The
// list now carries the RESOLVED capability set (routes/devices.js), so a device that declares
// nothing still reads as its platform baseline and keeps being polled exactly as today.
const canShot = !Array.isArray(device.capabilities) || device.capabilities.includes('remote.screenshot');
return `
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" data-can-screenshot="${canShot ? '1' : '0'}" onclick="window.location.hash='/device/${device.id}'">
<label class="device-card-select" title="${t('dashboard.select_for_wall')}" onclick="event.stopPropagation()">
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
</label>
<div class="device-card-preview" id="preview-${device.id}">
<div class="device-card-preview" id="preview-${device.id}" data-orientation="${esc(device.orientation || 'landscape')}">
${screenshotUrl
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
: `<div class="no-preview">
@ -174,7 +195,9 @@ function renderWallCard(wall) {
cells.push(`<div class="wall-card-cell${dev ? ' filled' : ''}" title="${dev ? esc(dev.device_name) : '[' + c + ',' + r + ']'}"></div>`);
}
}
const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length;
const members = wall.devices || [];
const onlineCount = members.filter(d => d.device_status === 'online').length;
const allUp = onlineCount === members.length && members.length > 0;
return `
<div class="device-card wall-card" data-wall-id="${wall.id}" onclick="window.location.hash='#/wall/${wall.id}'">
<div class="device-card-preview wall-card-preview">
@ -187,8 +210,20 @@ function renderWallCard(wall) {
<div class="device-card-body">
<div class="device-card-name">${esc(wall.name)}</div>
<div class="device-card-meta">
<div class="meta-item">${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}</div>
<div class="meta-item" style="color:${onlineCount === (wall.devices || []).length ? 'var(--success)' : 'var(--text-muted)'}">${onlineCount} online</div>
<div class="meta-item">${members.length} ${members.length === 1 ? 'tile' : 'tiles'}</div>
<div class="meta-item" style="color:${allUp ? 'var(--success)' : 'var(--danger, #e5484d)'}">${allUp ? 'all online' : `${onlineCount}/${members.length} online`}</div>
</div>
<!-- #235: a wall replaces its members' cards, so without this strip one dead panel of a
four-panel wall is invisible from the dashboard. Each chip links straight to the
device page being in a wall must not cost device-level visibility. -->
<div class="wall-card-members" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px">
${members.map(d => `
<a class="wall-card-member" href="#/device/${esc(d.device_id)}" data-member-device-id="${esc(d.device_id)}" onclick="event.stopPropagation()"
title="${esc(d.device_name)} — ${esc(d.device_status || 'unknown')}. Open device info & controls"
style="display:inline-flex;align-items:center;gap:4px;max-width:120px;padding:1px 6px;border:1px solid var(--border);border-radius:10px;font-size:10px;color:var(--text-secondary);text-decoration:none">
<span class="status-dot ${esc(d.device_status || 'offline')}" style="display:inline-block;flex-shrink:0"></span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.device_name)}</span>
</a>`).join('')}
</div>
</div>
</div>
@ -255,6 +290,47 @@ function renderGroupSection(group, devices, playlists) {
`;
}
/*
* Asks, once, whether this install will share its screen count. Only a platform admin sees it,
* and only while the decision is genuinely unmade BOTH answers persist, so it never returns
* after an update. Re-prompting is how telemetry earns its reputation and gets patched out.
*/
async function renderStatsPrompt(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (!isPlatformAdmin(user)) return;
let info;
try { info = await api.adminGetTelemetry(); } catch { return; }
if (info.state !== 'unasked') return;
const el = document.createElement('div');
el.className = 'settings-section';
el.style.cssText = 'margin-bottom:16px;display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap';
el.innerHTML = `
<div style="flex:1;min-width:260px">
<strong>Help show how widely ScreenTinker is deployed?</strong>
<p style="color:var(--text-muted);font-size:13px;margin:6px 0 0">
Because most installs are private, we can't tell how many screens are out there. Sharing
sends a random ID, the version, and how many screens you run nothing else, ever.
You can change this any time in Settings.
</p>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="statsYes">Share</button>
<button class="btn btn-secondary btn-sm" id="statsNo">No thanks</button>
</div>
`;
container.prepend(el);
const answer = async (enabled) => {
try { await api.adminSetTelemetry(enabled); } catch { /* leave it unasked; it can ask again later */ return; }
el.remove();
if (enabled) showToast('Thank you — sharing install statistics', 'success');
};
el.querySelector('#statsYes').addEventListener('click', () => answer(true));
el.querySelector('#statsNo').addEventListener('click', () => answer(false));
}
export function render(container) {
container.innerHTML = `
<div class="page-header">
@ -397,6 +473,10 @@ export function render(container) {
// Load everything
loadDashboard();
// Ask once about sharing install statistics. Fire-and-forget: it prepends itself if and only
// if the decision is still unmade, and a failure here must never affect the dashboard.
renderStatsPrompt(container).catch(() => {});
// Real-time updates
statusHandler = (data) => {
const b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text
@ -405,6 +485,14 @@ export function render(container) {
const statusEl = card.querySelector('.device-card-status');
if (statusEl) statusEl.innerHTML = `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`;
});
// #235: a wall member has no card of its own, only a chip on the wall card. Without this a
// panel could go offline and the dashboard would keep showing it green until a full reload —
// exactly the blind spot the issue is about.
document.querySelectorAll(`.wall-card-member[data-member-device-id="${CSS.escape(data.device_id)}"]`).forEach(chip => {
const dot = chip.querySelector('.status-dot');
if (dot) dot.className = `status-dot ${b.state}`;
chip.title = `${chip.title.split(' — ')[0]}${b.label}. Open device info & controls`;
});
};
screenshotHandler = (data) => {
@ -417,6 +505,7 @@ export function render(container) {
const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || '';
preview.innerHTML = `<img src="${imgSrc}" alt="Screenshot" loading="lazy">${statusHtml}`;
}
frameCard(preview); // the branch above can swap the img element out from under us
});
};
@ -446,18 +535,14 @@ export function render(container) {
for (const id of playbackByDevice.keys()) renderProgressFor(id);
}, 1000);
// Request fresh screenshots on load
setTimeout(() => {
document.querySelectorAll('.device-card').forEach(card => {
// Request fresh screenshots on load — from the panels that can actually take one.
const pollScreenshots = () => {
document.querySelectorAll('.device-card[data-can-screenshot="1"]').forEach(card => {
requestScreenshot(card.dataset.deviceId);
});
}, 2000);
refreshInterval = setInterval(() => {
document.querySelectorAll('.device-card').forEach(card => {
requestScreenshot(card.dataset.deviceId);
});
}, 30000);
};
setTimeout(pollScreenshots, 2000);
refreshInterval = setInterval(pollScreenshots, 30000);
}
function refreshSelectionBar() {
@ -659,6 +744,7 @@ async function loadDashboard() {
}
main.innerHTML = html;
frameCardScreenshots();
attachGroupHandlers(groupsWithDevices, dashboardDevices);
// Drop any selections for devices that have since been absorbed into a

View file

@ -576,7 +576,7 @@ function redraw() {
break;
case 'countdown':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
<div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${el.label || ''}</div>
<div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${esc(el.label || '')}</div>
<div style="font-size:${el.fontSize / 10}cqw;font-weight:bold" id="countdown_${i}"></div>
</div>`;
break;
@ -695,7 +695,7 @@ function updateProps() {
<div class="form-group"><label>${t('designer.prop.opacity')}</label><input type="range" min="0" max="1" step="0.1" value="${el.opacity}" data-prop="opacity" style="width:100%"></div>
<div class="form-group"><label>${t('designer.prop.shape')}</label><select class="input" style="background:var(--bg-input)" data-prop="shape"><option ${el.shape === 'rect' ? 'selected' : ''}>rect</option><option ${el.shape === 'circle' ? 'selected' : ''}>circle</option></select></div>`;
} else if (el.type === 'weather') {
html += `<div class="form-group"><label>${t('designer.prop.location')}</label><input type="text" class="input" value="${el.location}" data-prop="location"></div>
html += `<div class="form-group"><label>${t('designer.prop.location')}</label><input type="text" class="input" value="${esc(el.location)}" data-prop="location"></div>
<div class="form-group"><label>${t('widget.field.units')}</label><select class="input" data-prop="units">
<option value="imperial" ${el.units !== 'metric' ? 'selected' : ''}>${t('widget.field.units_imperial')}</option>
<option value="metric" ${el.units === 'metric' ? 'selected' : ''}>${t('widget.field.units_metric')}</option>
@ -709,7 +709,7 @@ function updateProps() {
<div class="form-group"><label>${t('designer.prop.bg_color')}</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`;
} else if (el.type === 'countdown') {
html += `<div class="form-group"><label>${t('designer.prop.target_date')}</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div>
<div class="form-group"><label>${t('designer.prop.label')}</label><input type="text" class="input" value="${el.label}" data-prop="label"></div>
<div class="form-group"><label>${t('designer.prop.label')}</label><input type="text" class="input" value="${esc(el.label)}" data-prop="label"></div>
<div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="100" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
<div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
}
@ -797,7 +797,7 @@ function generateInnerHTML() {
break;
case 'countdown':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color}">
<div style="font-size:${fsLabel}vw;opacity:0.8">${el.label}</div>
<div style="font-size:${fsLabel}vw;opacity:0.8">${esc(el.label)}</div>
<div style="font-size:${fs}vw;font-weight:bold" id="cd${i}"></div></div>
<script>setInterval(()=>{const d=new Date('${el.targetDate}')-new Date();if(d<=0){document.getElementById('cd${i}').textContent='NOW!';return}document.getElementById('cd${i}').textContent=Math.floor(d/864e5)+'d '+Math.floor(d%864e5/36e5)+'h '+Math.floor(d%36e5/6e4)+'m'},6e4)</script>`;
break;

View file

@ -4,6 +4,7 @@ import { showToast } from '../components/toast.js';
import { esc, livenessBadge, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js';
// The player distinguishes three cases for the Wi-Fi name, because "--" was hiding a real
// answer: Android 8.1+ refuses to reveal the SSID to an app without location permission, and a
@ -15,6 +16,14 @@ function ssidLabel(ssid) {
return esc(ssid);
}
// #238: turn the Now Playing screenshot the way the wall mount turns the panel. The placeholder
// ("no screenshot yet") is deliberately left alone — it is dashboard chrome, not device output.
function frameNowPlaying() {
const stage = document.getElementById('screenshotStage');
const img = document.getElementById('currentScreenshot');
if (stage && img && img.tagName === 'IMG') frameDeviceOutput(stage, img, currentDevice?.orientation);
}
let currentDevice = null;
let statusHandler = null;
let screenshotHandler = null;
@ -24,6 +33,81 @@ let shellHandler = null;
let diagPollTimer = null; // polls a diag-smoothness widget's reported frame stats while the page is open
let screenshotInterval = null;
let remoteActive = false;
// Mirrors the Debug-logging checkbox so cleanup() can switch the device's stream back off.
// Without this, leaving the screen left the panel streaming into nothing: the device kept
// emitting, the dashboard kept relaying, and nobody was listening. The player carries its own
// auto-off as the backstop for the case this can't cover -- a tab that is killed, not closed.
let debugStreamOn = false;
let debugFrozen = false;
let debugHeld = []; // lines that arrived while frozen, replayed on resume
const DEBUG_PANEL_MAX = 500; // panel rows AND the held-while-frozen cap
// Every player sends a level and the panel used to render all four identically, so the one line
// that explains the fault sat in a wall of grey. Errors and warnings are why the operator opened it.
const DEBUG_LEVEL_COLOR = { e: '#f87171', w: '#fbbf24', d: '#64748b' };
function debugLineText(d) {
return `${new Date(d.ts || Date.now()).toLocaleTimeString()} [${d.tag || ''}] ${d.message || ''}`;
}
function appendDebugLine(d) {
const panel = document.getElementById('debugLogPanel');
if (!panel) return;
const line = document.createElement('div');
line.textContent = debugLineText(d); // textContent — no HTML injection
const tone = DEBUG_LEVEL_COLOR[(d.level || '').toLowerCase()];
if (tone) line.style.color = tone;
panel.appendChild(line);
while (panel.childElementCount > DEBUG_PANEL_MAX) panel.removeChild(panel.firstChild);
panel.scrollTop = panel.scrollHeight;
}
function updateDebugTools() {
const btn = document.getElementById('debugFreezeBtn');
const status = document.getElementById('debugLogStatus');
if (btn) btn.textContent = debugFrozen ? t('device.debug.resume') : t('device.debug.freeze');
if (status) {
// Say how many are waiting, so freezing never feels like the device went quiet.
status.textContent = debugFrozen
? (debugHeld.length >= DEBUG_PANEL_MAX
? t('device.debug.held_max', { n: debugHeld.length })
: t('device.debug.held', { n: debugHeld.length }))
: '';
}
}
function setDebugFrozen(frozen) {
debugFrozen = frozen;
if (!frozen) {
const held = debugHeld;
debugHeld = [];
for (const d of held) appendDebugLine(d); // resume shows what you missed, in order
}
updateDebugTools();
}
/*
* Clipboard with a fallback, because a self-hosted dashboard on plain http is NOT a secure context
* and `navigator.clipboard` is simply absent there the copy buttons elsewhere in this app quietly
* do nothing in that case. A debug log is precisely what a self-hoster wants to paste into an issue.
*/
async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
} catch (e) { /* fall through to the legacy path */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
// Belt for the orphaned-stream fix: if the tab is hidden/closed/backgrounded while a Remote session
// is live, stop it (the server also auto-stops on socket drop, but bfcache keeps the socket alive).
@ -93,6 +177,39 @@ function isBrightSignDevice(device) {
return String(device.platform || '').toLowerCase().includes('brightsign');
}
// Mirrors platformFamily() in server/lib/player-capabilities.js — SAME FOUR SIGNALS, SAME ORDER,
// so the UI and the server never disagree about what a device is.
//
// The precedence is the whole point and is easy to get wrong. An earlier version of this helper
// kept only the last test, and a Tizen TV registers `android_version: 'Tizen 6.5'` (see
// tizen/js/app.js) — non-empty, not "Web/..." — so every Samsung panel in the fleet classified as
// Android. It was invisible only because Tizen happens to declare remote.screenshot today; the
// moment that changes, a MediaProjection button appears on a TV that has no such API.
//
// Gates the MediaProjection capture bootstrap below, and that gate is deliberately Android-and-
// nothing-else — NOT "Android that cannot already capture".
//
// The tempting extra condition is to hide it once a panel declares remote.screenshot. Two reasons
// not to. First, the dashboard cannot tell "this device declared it" from "the server filled in a
// baseline": /api/devices/:id ships capabilitiesFor(), which resolves both into one array (see
// server/routes/devices.js), and the android baseline CONTAINS remote.screenshot — so that
// condition hides the button from every one of the ~440 undeclared panels in the field, which is
// exactly backwards. Second, even where capture already works it is the accessibility path;
// MediaProjection is the better one (WebSocketService tries it FIRST), so offering the upgrade to
// a panel that has the weaker path is a feature, not redundancy.
function isAndroidDevice(device) {
if (!device) return false;
const platform = String(device.platform || '').toLowerCase();
if (platform.includes('brightsign')) return false;
if (platform.includes('tizen')) return false;
// Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt'. `platform`
// is the primary key, but it lives in a column an older client's register could overwrite.
if (device.client_type === 'wgt') return false;
if (device.client_type === 'apk') return true;
const av = String(device.android_version || '');
return av !== '' && !av.startsWith('Web/');
}
export function render(container, deviceId) {
container.innerHTML = `
<div class="device-detail">
@ -144,6 +261,10 @@ export function render(container, deviceId) {
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
screenshotEl.replaceWith(img);
}
// #238: a screenshot is the RAW framebuffer, so a portrait panel's arrives sideways — the
// player rotated the content into it and only the wall mount turns it back. Re-frame on every
// arrival, not just at render: the branch above swaps the element out from under us.
frameNowPlaying();
}
// Update remote canvas
const canvas = document.getElementById('remoteCanvas');
@ -171,14 +292,16 @@ export function render(container, deviceId) {
// checkbox is on). Appended via textContent — no HTML injection.
logHandler = (data) => {
if (data.device_id !== deviceId) return;
const panel = document.getElementById('debugLogPanel');
if (!panel) return;
const line = document.createElement('div');
const time = new Date(data.ts || Date.now()).toLocaleTimeString();
line.textContent = `${time} [${data.tag || ''}] ${data.message || ''}`;
panel.appendChild(line);
while (panel.childElementCount > 500) panel.removeChild(panel.firstChild);
panel.scrollTop = panel.scrollHeight;
// Frozen: HOLD the line rather than drop it. A log you froze to read something is the exact
// moment the lines that explain it are still arriving — pausing the stream would throw away
// the part you were about to want.
if (debugFrozen) {
debugHeld.push(data);
if (debugHeld.length > DEBUG_PANEL_MAX) debugHeld.shift();
updateDebugTools();
return;
}
appendDebugLine(data);
};
on('device-status', statusHandler);
@ -214,7 +337,7 @@ async function loadDevice(deviceId, activeTab = null) {
contentEl.innerHTML = `
<div class="device-header">
<div class="device-header-left">
<h1 id="deviceName">${device.name}</h1>
<h1 id="deviceName">${esc(device.name)}</h1>
${(() => { const b = livenessBadge(device); return `<span class="device-status-badge ${b.state}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`; })()}
${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''}
</div>
@ -260,7 +383,7 @@ async function loadDevice(deviceId, activeTab = null) {
<!-- Now Playing Tab -->
<div class="tab-content active" id="tab-nowplaying">
<div class="screenshot-container">
<div class="screenshot-container" id="screenshotStage">
${device.screenshot
? `<img id="currentScreenshot" src="/api/devices/${device.id}/screenshot?t=${Date.now()}&token=${localStorage.getItem('token')}" alt="Current screen">`
: `<div class="no-screenshot" id="currentScreenshot">
@ -269,7 +392,10 @@ async function loadDevice(deviceId, activeTab = null) {
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span>${t('device.no_screenshot')}</span>
<!-- The default copy tells the operator to click a button that is only rendered
for a panel that can capture. On one that cannot, pointing at a control that
is not on the page reads as a broken dashboard. -->
<span>${can('remote.screenshot') ? t('device.no_screenshot') : t('device.no_screenshot_unsupported')}</span>
</div>`
}
</div>
@ -334,6 +460,63 @@ async function loadDevice(deviceId, activeTab = null) {
<!-- Info Tab -->
<div class="tab-content" id="tab-info">
${diagWidget ? renderDiagPanel(diagWidget) : ''}
<!-- The actions an operator opens this page to take. They used to sit below the info
grid, the reboot schedule and the debug log panel, which on a phone meant scrolling
past everything to reach the one button you came for. Kept as a single wrapping row
so a narrow screen reflows rather than clipping, and each button still renders only
where the display can honour it. -->
<div style="margin:20px 0;display:flex;gap:8px;flex-wrap:wrap">
${can('system.reboot') ? `
<button class="btn btn-secondary btn-sm" id="rebootBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
${t('device.ctl.reboot_device')}
</button>` : ''}
${can('display.power') ? `
<button class="btn btn-secondary btn-sm" id="screenOffBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
${t('device.ctl.screen_off')}
</button>` : ''}
${can('display.power') ? `
<button class="btn btn-secondary btn-sm" id="screenOnBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
${t('device.ctl.screen_on')}
</button>` : ''}
${can('system.restart_player') ? `
<button class="btn btn-secondary btn-sm" id="launchAppBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
${t('device.ctl.launch_player')}
</button>` : ''}
${can('system.self_update') ? `
<button class="btn btn-secondary btn-sm" id="forceUpdateBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
${t('device.ctl.force_update')}
</button>
<button class="btn btn-secondary btn-sm" id="clearUpdateCacheBtn" title="${t('device.ctl.clear_update_cache_tip')}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
${t('device.ctl.clear_update_cache')}
</button>` : ''}
${can('system.reboot') ? `
<button class="btn btn-danger btn-sm" id="shutdownBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
${t('device.ctl.shutdown')}
</button>` : ''}
</div>
<div class="info-grid">
<div class="info-card">
<div class="info-card-label">${t('device.info.status')}</div>
@ -350,6 +533,15 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.local_ip')}</div>
<div class="info-card-value small" id="telLocalIp">${device.local_ip || '--'}</div>
</div>
${device.local_ip6 ? `
<div class="info-card">
<!-- Rendered only when the panel actually has one. A v6 address is long, and showing an
empty row for the overwhelmingly v4 fleet would cost every operator screen space to
tell them nothing. A dual-stack panel shows both cards; a v6-only panel used to
show a dash here and nothing else, because the player only ever collected v4. -->
<div class="info-card-label">${t('device.info.local_ip6')}</div>
<div class="info-card-value small" id="telLocalIp6">${device.local_ip6}</div>
</div>` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.battery')}</div>
@ -391,10 +583,15 @@ async function loadDevice(deviceId, activeTab = null) {
</div>` : ''}
${latestTelemetry.storage_total_mb ? `
<div class="info-card">
<!-- Labelled "player storage", not "storage": on this family the number is the
widget's cache quota, not the device filesystem. Same column as Android's real
disk figures, so the label is what stops it being read as "the disk is 1 GB". -->
<div class="info-card-label">${t('device.info.player_storage')}</div>
<!-- This used to be labelled "player storage" because the number WAS the widget's
cache quota rather than the disk a real XT245 with a 119 GB NVMe reported
"1026 MB", and the label was the only thing stopping that being read as the disk
size. The bridge now reads the actual filesystem (statfs over the mounts under
/storage, largest wins), so it means the same thing as Android's figure and is
labelled the same. The bridge is served per page load, so a player that has not
re-fetched it yet still reports the quota see the CDN caching note in
docs/player-parity.md before trusting a suspiciously round ~1 GB here. -->
<div class="info-card-label">${t('device.info.storage')}</div>
<div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb != null ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}</div>
<div class="progress-bar">
<div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}"
@ -407,6 +604,20 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.temperature')}</div>
<div class="info-card-value small" id="telTemp">${latestTelemetry.temperature_c}&deg;C</div>
</div>` : ''}
<!-- The physical panel, from its EDID, and the mode the output is negotiated to. Shown
only when the player reports them, like every other card here: a family that cannot
read its own output must not grow an empty row. On a dual-output player each device
row is one output, so this is THAT output's screen — not the box's first. -->
${latestTelemetry.attached_display ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.attached_display')}</div>
<div class="info-card-value small" id="telDisplay">${esc(latestTelemetry.attached_display)}</div>
</div>` : ''}
${latestTelemetry.video_mode ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.video_mode')}</div>
<div class="info-card-value small" id="telVideoMode">${esc(latestTelemetry.video_mode)}</div>
</div>` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.wifi')}</div>
@ -452,11 +663,18 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.clock.label')}</div>
<div class="info-card-value small">${renderDeviceClock(device)}</div>
</div>
${device.android_version && !device.android_version.startsWith('Web/') ? `
<!-- Shown for Android as before, and now for ANY player that actually reports the value.
These were platform-gated when Android was the only family that could measure them;
a BrightSign widget runs with nodejs_enabled and the bridge reads os.totalmem/freemem
and the load average, so the numbers exist and were being thrown away by a gate that
asked what the device IS instead of what it SENT. Keeping the Android arm means a
panel that reports nothing still shows "--" there rather than losing its cards. -->
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.ram_free_mb != null ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.ram')}</div>
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}</div>
</div>
</div>` : ''}
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.cpu_usage != null ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.cpu_usage')}</div>
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>
@ -548,53 +766,19 @@ async function loadDevice(deviceId, activeTab = null) {
<input type="checkbox" id="debugLogToggle"> ${t('device.debug.toggle')}
</label>
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.debug.hint')}</div>
<!-- Freeze holds the view still WITHOUT dropping what arrives: a log you are reading
scrolls the interesting line off the top, and pausing the stream instead would lose
exactly the lines that follow the fault. Copy exists because the useful next step is
pasting this into an issue. -->
<div id="debugLogTools" style="display:none;margin-top:8px;gap:6px;align-items:center;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="debugFreezeBtn">${t('device.debug.freeze')}</button>
<button class="btn btn-secondary btn-sm" id="debugCopyBtn">${t('device.debug.copy')}</button>
<button class="btn btn-secondary btn-sm" id="debugClearBtn">${t('device.debug.clear')}</button>
<span id="debugLogStatus" style="font-size:11px;color:var(--text-muted)"></span>
</div>
<div id="debugLogPanel" style="display:none;margin-top:8px;background:#0b0f1a;border:1px solid var(--border);border-radius:6px;padding:8px;height:220px;overflow-y:auto;font-family:monospace;font-size:11px;line-height:1.45;color:#cbd5e1"></div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap">
${can('system.reboot') ? `
<button class="btn btn-secondary btn-sm" id="rebootBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
${t('device.ctl.reboot_device')}
</button>` : ''}
${can('display.power') ? `
<button class="btn btn-secondary btn-sm" id="screenOffBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
${t('device.ctl.screen_off')}
</button>` : ''}
${can('display.power') ? `
<button class="btn btn-secondary btn-sm" id="screenOnBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
${t('device.ctl.screen_on')}
</button>` : ''}
${can('system.restart_player') ? `
<button class="btn btn-secondary btn-sm" id="launchAppBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
${t('device.ctl.launch_player')}
</button>` : ''}
${can('system.self_update') ? `
<button class="btn btn-secondary btn-sm" id="forceUpdateBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
${t('device.ctl.force_update')}
</button>` : ''}
${can('system.reboot') ? `
<button class="btn btn-danger btn-sm" id="shutdownBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
${t('device.ctl.shutdown')}
</button>` : ''}
</div>
<!-- #109: PiP overlay tester. Pushes device:pip-show/clear via POST /api/pip
(real triggers are external via the API token; this is for testing). -->
@ -626,6 +810,11 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="remote-container">
${can('remote.stream') ? `
<div class="remote-screen" id="remoteScreen">
<!-- Deliberately NOT rotated with the rest of the previews (#238). This is a control
surface: taps and swipes are sent as fractions of THIS canvas, which is the raw
framebuffer the device replays them into, and it also shows the Android system UI
which really is landscape on a portrait-hung panel. Turning the picture without
inverting the touch mapping would send every tap to the wrong place. -->
<canvas id="remoteCanvas" width="960" height="540" style="background:#000;width:100%"></canvas>
<div class="no-screenshot" id="remoteOverlay" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
<div style="text-align:center">
@ -665,16 +854,17 @@ async function loadDevice(deviceId, activeTab = null) {
<button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">${t('device.remote.ok')}</button>
<hr style="border-color:var(--border);margin:8px 0">
<button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">${t('device.remote.settings')}</button>
${can('display.power') ? `
<hr style="border-color:var(--border);margin:8px 0">
<div style="display:flex;gap:4px">
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">${t('device.remote.scrn_off')}</button>
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">${t('device.remote.scrn_on')}</button>
</div>
</div>` : ''}
</div>` : ''}
${device.tier === 2 ? `
<span style="font-size:10px;color:var(--success);line-height:1.2;display:block;margin-top:8px">${t('device.remote.system_view_owner')}</span>
` : `
${can('remote.screenshot') ? `
${isAndroidDevice(device) ? `
<button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="${t('device.remote.system_view_tooltip')}" style="margin-top:8px">
${t('device.remote.enable_system_view')}
</button>
@ -796,6 +986,7 @@ async function loadDevice(deviceId, activeTab = null) {
// offline→online transitions derived from the status log).
renderIncidents(device.deviceEvents || [], device.statusLog || []);
frameNowPlaying();
setupTabs();
setupActions(device);
setupRemote(device);
@ -905,8 +1096,13 @@ function setupTabs() {
// same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT
// playlist in the device's OWN layout/orientation (server payload). wall members
// preview full-frame (server forces wall_config:null in v1).
//
// #238: the iframe is the panel's FRAMEBUFFER, not its face. It used to be given the as-displayed
// 9/16 shape directly, so on a portrait device the player rotated content a second time inside a
// box that was already the finished picture and the preview came out sideways — while the panel
// itself was right, which is the worst possible split for someone trying to verify their work.
// The stage is the face; the frame is landscape underneath it and the mount turns it back.
function showDevicePreview(device) {
const portrait = (device.orientation || '').includes('portrait');
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
@ -916,10 +1112,13 @@ function showDevicePreview(device) {
<button class="btn btn-secondary btn-sm" id="dpvClose">${t('widget.close')}</button>
</div>
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
<iframe style="height:78vh;max-width:92vw;aspect-ratio:${portrait ? '9 / 16' : '16 / 9'};border:0;background:#000" src="/player?preview=1&device=${encodeURIComponent(device.id)}&t=${Date.now()}"></iframe>
<div id="dpvStage" style="height:78vh;max-width:92vw;aspect-ratio:${displayAspectRatio(device.orientation)};background:#000">
<iframe style="border:0;background:#000" src="/player?preview=1&device=${encodeURIComponent(device.id)}&t=${Date.now()}"></iframe>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
frameDeviceOutput(overlay.querySelector('#dpvStage'), overlay.querySelector('#dpvStage iframe'), device.orientation);
const close = () => overlay.remove();
overlay.querySelector('#dpvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
@ -1053,10 +1252,16 @@ function setupActions(device) {
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
// Screenshot button
// Screenshot button — pass a callback so the server's verdict surfaces as a toast
// instead of the request silently going nowhere (offline device, or a player type
// that can't capture at all, e.g. BrightSign).
document.getElementById('screenshotBtn')?.addEventListener('click', () => {
requestScreenshot(device.id);
showToast(t('device.toast.screenshot_requested'), 'info');
requestScreenshot(device.id, (ack) => {
if (ack?.delivered) showToast(t('device.toast.screenshot_requested'), 'info');
else if (ack?.reason === 'unsupported') showToast(t('device.toast.screenshot_unsupported'), 'warning');
else if (ack?.reason === 'offline') showToast(t('device.toast.screenshot_offline'), 'warning');
else showToast(t('device.toast.screenshot_failed'), 'error');
});
});
// Rename
@ -1097,9 +1302,36 @@ function setupActions(device) {
const enabled = e.target.checked;
const panel = document.getElementById('debugLogPanel');
if (panel) panel.style.display = enabled ? 'block' : 'none';
const tools = document.getElementById('debugLogTools');
if (tools) tools.style.display = enabled ? 'flex' : 'none';
debugStreamOn = enabled;
// Unticking and reticking should not resume into a frozen panel the operator forgot about.
if (!enabled) { debugFrozen = false; debugHeld = []; }
updateDebugTools();
sendCommand(device.id, 'set_debug', { enabled });
});
document.getElementById('debugFreezeBtn')?.addEventListener('click', () => setDebugFrozen(!debugFrozen));
document.getElementById('debugClearBtn')?.addEventListener('click', () => {
const panel = document.getElementById('debugLogPanel');
if (panel) panel.textContent = '';
debugHeld = [];
updateDebugTools();
});
document.getElementById('debugCopyBtn')?.addEventListener('click', async () => {
const panel = document.getElementById('debugLogPanel');
// Copy what is ON SCREEN. Anything held while frozen is deliberately excluded — the operator
// is copying the capture they are looking at, and silently appending lines they have not seen
// would make the paste disagree with the panel.
const text = panel ? [...panel.children].map((el) => el.textContent).join('\n') : '';
if (!text) { showToast(t('device.debug.copy_empty'), 'error'); return; }
const header = `${device.name || device.id}${device.platform || ''} ${device.hardware_model || ''}${new Date().toISOString()}`.trim();
const ok = await copyToClipboard(`${header}\n${'-'.repeat(header.length)}\n${text}\n`);
showToast(ok ? t('device.debug.copied', { n: panel.childElementCount }) : t('device.debug.copy_failed'), ok ? 'success' : 'error');
});
document.getElementById('saveNotesBtn')?.addEventListener('click', async () => {
try {
await api.updateDevice(device.id, {
@ -1367,6 +1599,12 @@ function setupActions(device) {
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
});
// Drops every staged APK on the panel so the next check downloads afresh. The escape hatch for a
// player holding a bad download — a cached file that cannot install but is reused every attempt.
document.getElementById('clearUpdateCacheBtn')?.addEventListener('click', () => {
sendWithFeedback('clear_update_cache', 'Clear update cache', 'device.toast.update_cache_cleared');
});
// #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
const uri = (document.getElementById('pipUri')?.value || '').trim();
@ -1549,7 +1787,7 @@ async function setupPlaylistActions(device) {
${zones.length > 0 ? `
<select id="assignZone" class="input" style="background:var(--bg-input)">
<option value="">${t('device.assign.zone_default')}</option>
${zones.map(z => `<option value="${z.id}">${z.name} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')}
${zones.map(z => `<option value="${z.id}">${esc(z.name)} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')}
</select>
` : !device.layout_id ? `
<div style="font-size:12px;color:var(--text-muted);padding:6px 0;line-height:1.5">${t('device.assign.zone_no_layout')}</div>
@ -1561,7 +1799,9 @@ async function setupPlaylistActions(device) {
</div>
<div class="form-group">
<label>${t('device.assign.duration_label')}</label>
<input type="number" id="assignDuration" class="input" value="10" min="1" max="3600">
<!-- max is the server's absurd-duration ceiling (12h): a feature-length clip
pre-filled from its own length must not land in an out-of-range field. -->
<input type="number" id="assignDuration" class="input" value="10" min="1" max="43200">
</div>
<!-- Tabs -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:12px">
@ -1572,7 +1812,7 @@ async function setupPlaylistActions(device) {
<!-- Media grid -->
<div class="assign-content-grid" id="assignMedia">
${content.map(c => `
<div class="assign-content-item" data-content-id="${c.id}" data-type="content">
<div class="assign-content-item" data-content-id="${c.id}" data-type="content" data-duration="${Number(c.duration_sec) > 0 ? Math.ceil(c.duration_sec) : ''}">
${c.thumbnail_path
? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="">`
: c.remote_url
@ -1596,7 +1836,7 @@ async function setupPlaylistActions(device) {
<div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">
${icons[w.widget_type] || '&#9881;'}
</div>
<div class="assign-content-item-name">${w.name}</div>
<div class="assign-content-item-name">${esc(w.name)}</div>
</div>`;
}).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_widgets')} <a href="#/widgets" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
</div>
@ -1605,7 +1845,7 @@ async function setupPlaylistActions(device) {
${kioskPages.map(k => `
<div class="assign-content-item" data-content-id="${k.id}" data-type="kiosk">
<div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">&#128433;</div>
<div class="assign-content-item-name">${k.name}</div>
<div class="assign-content-item-name">${esc(k.name)}</div>
</div>
`).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_kiosk')} <a href="#/kiosk" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
</div>
@ -1632,12 +1872,21 @@ async function setupPlaylistActions(device) {
let selectedId = null;
let selectedType = null;
// #237: this modal always SENDS a duration, so the server's "default a video to its own
// length" rule can never fire here — the field has to carry the clip length itself, or
// picking a 32s video silently assigns a 10s item that cuts off. Anything the operator
// typed is theirs and is never overwritten.
const durInput = modal.querySelector('#assignDuration');
let durationTouched = false;
durInput?.addEventListener('input', () => { durationTouched = true; });
modal.querySelectorAll('.assign-content-item').forEach(item => {
item.addEventListener('click', () => {
modal.querySelectorAll('.assign-content-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
selectedId = item.dataset.contentId;
selectedType = item.dataset.type;
const clip = parseInt(item.dataset.duration || '', 10);
if (durInput && !durationTouched) durInput.value = clip > 0 ? clip : 10;
});
});
@ -2028,6 +2277,9 @@ function updateTelemetryDisplay(telemetry) {
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
if (telemetry.wifi_ssid !== undefined) update('telWifi', ssidLabel(telemetry.wifi_ssid));
if (telemetry.local_ip) update('telLocalIp', telemetry.local_ip);
// update() no-ops when the card is absent, which is the case for a v4-only panel — a screen that
// acquires a v6 address mid-session picks the card up on the next full render, not this path.
if (telemetry.local_ip6) update('telLocalIp6', telemetry.local_ip6);
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));
@ -2104,6 +2356,12 @@ export function cleanup() {
if (shellHandler) off('shell-result', shellHandler); // #161 owner-tools listener
if (screenshotInterval) clearInterval(screenshotInterval);
if (remoteActive && currentDevice) stopRemote(currentDevice.id);
// Same reasoning as stopRemote above: an operator who navigates away has stopped watching, so
// the display should stop talking. Must run BEFORE currentDevice is cleared.
if (debugStreamOn && currentDevice) sendCommand(currentDevice.id, 'set_debug', { enabled: false });
debugStreamOn = false;
debugFrozen = false;
debugHeld = [];
remoteActive = false;
currentDevice = null;
window._sendKey = null;

View file

@ -20,7 +20,7 @@ export function render(container) {
{ icon: '&#128197;', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] },
{ icon: '&#128421;', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] },
{ icon: '&#128433;', title: 'Kiosk/Touchscreen', steps: ['Go to Kiosk and create a new page', 'Add buttons with labels, icons, and actions', 'Configure the idle screen timeout', 'Preview the page in the editor', 'Assign to a device as a widget'] },
{ icon: '&#127916;', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Set the grid size (e.g., 2x2)', 'Drag devices onto grid positions', 'Set bezel compensation if needed', 'Assign content to play across all displays'] },
{ icon: '&#127916;', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Drag displays onto the canvas and arrange them to match the PHYSICAL wall', 'Panel hung sideways? Select it and set "How this panel is mounted" — no need to pre-rotate your video', 'Set bezel compensation if needed, then "Fit player to screens"', 'Assign a playlist to play across all displays', 'The Panels list below the canvas shows each screen\'s online state and links to its device page'] },
].map(guide => `
<div class="settings-section" style="margin:0">
<h3 style="font-size:15px">${guide.icon} ${guide.title}</h3>
@ -44,6 +44,7 @@ export function render(container) {
{ q: 'Can I white-label the dashboard?', a: 'Yes! Go to Settings > White Label to customize the brand name, colors, logo, and domain.' },
{ q: 'How do I export proof-of-play reports?', a: 'Go to Reports, set your date range and filters, then click "Export CSV".' },
{ q: 'What is a video wall?', a: 'A video wall combines multiple displays into one large screen. For example, four TVs in a 2x2 grid showing one big image/video.' },
{ q: 'How do I build a wall from portrait (sideways-mounted) panels?', a: 'Arrange the tiles on the wall canvas exactly as the panels are hung — side by side stays side by side. Then select each tile and set "How this panel is mounted" to match how it was turned. The player rotates the content for you, so you do not need a pre-rotated copy of your video. While a display is in a wall, this setting replaces its own Orientation.' },
].map(faq => `
<div style="border-bottom:1px solid var(--border);padding:12px 0">
<div style="font-weight:600;font-size:14px;margin-bottom:4px">${faq.q}</div>

View file

@ -1,5 +1,33 @@
import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { esc } from '../utils.js';
/*
* A recognisable mark for the providers people expect to see, and an honest generic one for
* everything else. Inline SVG rather than a remote image: an <img> to a provider CDN would put a
* third-party origin back into the CSP, which is precisely what moving the flow server-side removed.
*/
const PROVIDER_ICONS = {
google: `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>`,
microsoft: `<svg width="18" height="18" viewBox="0 0 21 21" aria-hidden="true">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>`,
};
const GENERIC_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>`;
const providerIcon = (slug) => PROVIDER_ICONS[slug] || GENERIC_ICON;
let authConfig = null;
@ -77,8 +105,19 @@ export async function render(container) {
<input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
</div>
<div class="form-group">
<label>${t('auth.password')}</label>
<label id="loginPasswordLabel" for="loginPassword">${t('auth.password')}</label>
<input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password">
<!-- Filled in only when the typed email belongs to an organization that has configured
its own identity provider. A customer's IdP is never listed to everyone: the button
appears for the people it belongs to and nobody else, which also keeps the customer
list off the login page.
BELOW the input, inside the same group. Above it, the button sat between the
"Password" label and its field so the label described the SSO button and the
password box had none at all. It has to stay INSIDE the group, because hiding the
group is how the password is hidden and the button must survive that... which is
exactly why setPasswordVisible() hides the FIELD, never the container. -->
<div id="orgSsoSlot" style="display:none;margin-top:12px"></div>
</div>
${isSetup ? `
<div class="form-group">
@ -148,39 +187,32 @@ export async function render(container) {
</div>
<div id="ssoBlock">
${config.googleEnabled || config.microsoftEnabled ? `
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
${(config.providers || []).length ? `
<div id="ssoDivider" style="display:flex;align-items:center;gap:12px;margin:20px 0">
<hr style="flex:1;border-color:var(--border)">
<span style="color:var(--text-muted);font-size:12px">${t('auth.divider_or')}</span>
<hr style="flex:1;border-color:var(--border)">
</div>
` : ''}
${config.googleEnabled ? `
<div id="googleSignInContainer">
<button class="btn btn-secondary" id="googleSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px">
<svg width="18" height="18" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
${t('auth.signin_google')}
</button>
<!-- One button per configured provider, and each is a plain LINK to a server endpoint.
There is no provider SDK on this page: the browser never speaks to the identity
provider directly, so nothing here needs a client id and the CSP needs no
third-party script origin. Google and Microsoft are ordinary entries in this list.
The icon is chosen by slug where we have one and falls back to a generic mark, so a
self-hoster's Keycloak or Authentik still gets a real-looking button. -->
<!-- Wrapped so the whole set can be hidden at once: an organization that REQUIRES its own
identity provider must not be shown the operator's, which are not domain-confined. -->
<div id="instanceProviders">
${(config.providers || []).map((p) => `
<a class="btn btn-secondary" href="/api/auth/oidc/${encodeURIComponent(p.slug)}/start"
id="sso-${esc(p.slug)}"
style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px;text-decoration:none">
${providerIcon(p.slug)}
${esc(t('auth.signin_with', { provider: p.name }))}
</a>
`).join('')}
</div>
` : ''}
${config.microsoftEnabled ? `
<button class="btn btn-secondary" id="microsoftSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px">
<svg width="18" height="18" viewBox="0 0 21 21">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
${t('auth.signin_microsoft')}
</button>
` : ''}
</div>
</div>
@ -255,7 +287,15 @@ function setupHandlers(config, isSetup) {
if (isSetup) {
document.getElementById('loginBtn')?.addEventListener('click', () => doRegister(true));
} else {
document.getElementById('loginBtn')?.addEventListener('click', doLogin);
/*
* Identifier-first. The button is "Next" until an address has been submitted: we ask the server
* what that address uses BEFORE offering a credential, so an SSO-only user is never shown a
* password box that is going to be refused, and the org lookup has somewhere to happen.
*/
document.getElementById('loginBtn')?.addEventListener('click', () => {
if (identified && !ssoOnlyDomain) return doLogin();
identify();
});
document.getElementById('showRegisterBtn')?.addEventListener('click', () => {
document.getElementById('localAuthForm').style.display = 'none';
document.getElementById('registerForm').style.display = 'block';
@ -272,6 +312,40 @@ function setupHandlers(config, isSetup) {
if (e.key === 'Enter') isSetup ? doRegister(true) : doLogin();
});
/*
* Enter in the EMAIL field advances rather than submitting. During first-run setup both fields
* are needed at once, so identifier-first is skipped entirely there.
*/
document.getElementById('loginEmail')?.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
if (isSetup) return doRegister(true);
if (identified && !ssoOnlyDomain) return doLogin();
identify();
});
/*
* Editing the address after identifying returns to the identifier step. Someone who mistypes
* their domain must get a fresh answer rather than keep the previous domain's one.
*/
document.getElementById('loginEmail')?.addEventListener('input', () => {
if (!identified) return;
identified = false;
applyFormState();
});
/*
* Ask what this address uses, then show the right thing. The lookup itself sets ssoOnlyDomain via
* setPasswordVisible(), so this only has to decide that we now know who is signing in.
*/
async function identify() {
const email = document.getElementById('loginEmail').value.trim();
if (!email || !email.includes('@')) { showError(t('auth.error_email_required')); return; }
try { await lookupOrgSso(email); } catch { /* lookup failures fall through to the password box */ }
identified = true;
applyFormState();
if (!ssoOnlyDomain) document.getElementById('loginPassword')?.focus();
}
async function doLogin() {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
@ -284,6 +358,12 @@ function setupHandlers(config, isSetup) {
body: JSON.stringify({ email, password })
});
const data = await res.json();
/*
* The organization requires its identity provider, so this is not a credential failure and
* must not read like one "invalid password" sends the user to reset a password that will
* never work again. Point them at the control that does work.
*/
if (!res.ok && data.code === 'sso_required') { showError(t('auth.sso_required')); return; }
if (!res.ok) { showError(data.error); return; }
// Unverified account (hosted hard-gate): no session — prompt to check email.
if (data.verification_required) { showVerifyNotice(data.email || email); return; }
@ -451,66 +531,247 @@ function setupHandlers(config, isSetup) {
}
}
// Google Sign-In
if (config.googleEnabled) {
document.getElementById('googleSignInBtn')?.addEventListener('click', async () => {
try {
// Use Google's popup-based sign in
const client = google.accounts.oauth2.initTokenClient({
client_id: config.googleClientId,
scope: 'email profile',
callback: async (response) => {
if (response.access_token) {
// Get ID token via Google's tokeninfo
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${response.access_token}`);
const tokenData = await tokenRes.json();
// Send to our server
const res = await fetch('/api/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: response.access_token, email: tokenData.email })
});
const data = await res.json();
if (res.ok) onAuthSuccess(data);
else showError(data.error);
}
}
});
client.requestAccessToken();
} catch (err) {
showError(t('auth.error_google_failed'));
}
});
/*
* SSO is a link, not a script.
*
* The buttons above are anchors to /api/auth/oidc/<slug>/start, so there is nothing to bind here
* and no SDK to wait for. What DOES need handling is the trip back: the callback redirects to
* #/login carrying either a session token or an error code.
*
* The token rides in the URL FRAGMENT, which browsers never send to servers and proxies never
* log and it is stripped from the address bar before anything else happens, so a shared screen
* or a copied URL does not carry a live session.
*/
/*
* Email-first SSO for organizations.
*
* Instance-wide providers are always on the page. An ORG provider is different it belongs to
* one customer so it is fetched by domain once the address looks complete, and only then.
*
* Debounced because this fires while someone types, and the endpoint is rate limited; asking on
* every keystroke would spend a user's whole budget before they finished their own address.
*/
let lastDomainAsked = '';
const orgSlot = () => document.getElementById('orgSsoSlot');
/*
* Show or hide the password half of the sign-in form.
*
* Presentation only the server refuses a password for these accounts regardless. Restoring it
* on every negative answer matters as much as hiding it: someone who types an SSO-only address,
* then corrects it to their own, must get the password box back.
*/
/*
* Password visibility has TWO independent drivers, and conflating them is how this got confusing:
*
* identified identifier-first. The password box does not exist until an address has been
* submitted, because until then we do not know whether this account uses a
* password at all. This is what lets the org lookup happen before we offer the
* wrong thing.
* ssoOnlyDomain the address belongs to an organization that REQUIRES its own provider. Then a
* password box is not merely going to fail, it is the wrong thing to show.
*
* The field appears only when identified AND not SSO-only. Kept as one function so the two can
* never disagree about what is on screen.
*/
let identified = false;
let ssoOnlyDomain = false;
function applyFormState() {
const showPassword = identified && !ssoOnlyDomain;
const show = showPassword ? '' : 'none';
/*
* Hide the password FIELD, never its .form-group the organization SSO slot lives inside
* that same group, so hiding the container took the single sign-on button down with it.
*/
for (const id of ['loginPassword', 'loginPasswordLabel']) {
const el = document.getElementById(id);
if (el) el.style.display = show;
}
/*
* The primary button is "Next" until an address has been submitted, then "Sign in". One button
* rather than two, so there is never a choice about which to press.
*/
const btn = document.getElementById('loginBtn');
if (btn) btn.textContent = identified && !ssoOnlyDomain ? t('auth.sign_in') : t('auth.next');
if (btn) btn.style.display = ssoOnlyDomain ? 'none' : '';
/*
* The instance's own providers stay visible at ALL times, by explicit decision: they are the
* operator's, they are offered to everyone, and the server refuses them for an SSO-only
* organization anyway. (Previously they were hidden for such domains so the page would not
* invite the bypass; the cost was a login page that changed shape while you typed.)
*/
/*
* "Create Account" and "Forgot your password?" DO go for an SSO-only domain: registration there
* is refused by the server, and a password reset produces one that can never be used.
*/
const reg = document.getElementById('showRegisterBtn');
if (reg) reg.style.display = ssoOnlyDomain ? 'none' : '';
const forgot = document.getElementById('forgotLink');
if (forgot) {
const wrap = forgot.parentElement && forgot.parentElement.tagName === 'P' ? forgot.parentElement : forgot;
wrap.style.display = ssoOnlyDomain ? 'none' : '';
}
}
// Microsoft Sign-In
if (config.microsoftEnabled) {
document.getElementById('microsoftSignInBtn')?.addEventListener('click', async () => {
try {
const msalConfig = {
auth: {
clientId: config.microsoftClientId,
authority: `https://login.microsoftonline.com/${config.microsoftTenantId}`,
redirectUri: window.location.origin
}
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
await msalInstance.initialize();
const loginResponse = await msalInstance.loginPopup({ scopes: ['User.Read'] });
if (loginResponse.accessToken) {
const res = await fetch('/api/auth/microsoft', {
// Kept for the org lookup below, which reasons about SSO-only rather than about identification.
function setPasswordVisible(visible) {
ssoOnlyDomain = !visible;
applyFormState();
}
async function lookupOrgSso(email) {
const at = String(email || '').lastIndexOf('@');
const domain = at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
const slot = orgSlot();
if (!slot) return;
// Nothing to ask about until there is a domain with a dot in it.
if (!domain || !domain.includes('.')) {
slot.style.display = 'none'; slot.innerHTML = ''; lastDomainAsked = ''; setPasswordVisible(true); return;
}
if (domain === lastDomainAsked) return;
try {
const res = await fetch(`/api/auth/sso/discover?email=${encodeURIComponent(email)}`);
/*
* Check the STATUS, not just that a body parsed.
*
* The comment below has always said a tripped rate limit must not poison the domain and it
* did anyway, because a 429 body is perfectly valid JSON: res.json() resolved, `data.sso`
* came back undefined, so the single sign-on button was hidden, the password box restored,
* and `lastDomainAsked` recorded permanently, for the life of the page. On an SSO-only
* domain that is the worst possible outcome: the password box the user is then offered gets
* 403, and the button they are told to use is not on the screen. Discover is 10/min per IP,
* so a handful of colleagues behind one office address is enough to trigger it.
*/
if (!res.ok) throw new Error(`discover ${res.status}`);
const data = await res.json();
// Remembered only after a SUCCESSFUL answer.
lastDomainAsked = domain;
if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; setPasswordVisible(true); return; }
/*
* When the organization REQUIRES its identity provider, the password box is not merely going
* to fail it is the wrong thing to offer. Showing it invites someone to type a password,
* be refused, and go and reset a password that will never work again. Hidden, not disabled,
* so there is one obvious way forward.
*/
setPasswordVisible(!data.required);
/*
* A FORM, not a link, and a deliberately generic label.
*
* The lookup tells us only that this domain uses SSO never which provider or whose it is,
* because that would identify a customer to anyone who guessed a domain. The server does the
* mapping again on submit, so the slug is never published to the page. POST keeps the address
* out of the URL, browser history and any Referer the provider's page would send.
*/
/*
* A BUTTON that fetches and then navigates not a form that submits.
*
* The dashboard's CSP is `form-action 'self'`, and Chrome applies it across the whole
* redirect chain, so a form POST that 302s on to the customer's identity provider was
* ABORTED with nothing shown to the user at all. The provider origins cannot be allowlisted
* because customers supply them. A script-initiated navigation is not covered by
* form-action, so the page asks the server where to go and goes there.
*
* Styled secondary: "Sign In" is the primary action while a password still works, and two
* identical blue buttons stacked one above the other sent people to their IdP by muscle
* memory after typing a password.
*/
slot.innerHTML = `
<button type="button" id="orgSsoBtn" class="btn ${data.required ? 'btn-primary' : 'btn-secondary'}"
style="width:100%;justify-content:center;padding:10px">
${t('auth.signin_sso')}
</button>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px;text-align:center">
${t('auth.sso_org_hint')}
</div>`;
slot.style.display = '';
const btn = slot.querySelector('#orgSsoBtn');
if (btn) btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const r = await fetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: loginResponse.accessToken })
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (res.ok) onAuthSuccess(data);
else showError(data.error);
const body = await r.json().catch(() => ({}));
if (!r.ok || !body.start_url) throw new Error(body.error || `start ${r.status}`);
window.location.assign(body.start_url);
} catch {
btn.disabled = false;
showError(t('auth.sso_err_provider_unavailable'));
}
} catch (err) {
showError(t('auth.error_microsoft_failed'));
});
} catch {
// A failed lookup must never block a password login — the form still works, and the password
// box comes back rather than leaving someone staring at a form with no way to submit it.
slot.style.display = 'none';
slot.innerHTML = '';
setPasswordVisible(true);
}
}
/*
* The lookup now runs on SUBMIT (identify()), not on every keystroke.
*
* Identifier-first made the debounced version both redundant and wrong: redundant because nothing
* is shown until an address is submitted anyway, and wrong because it would answer for a
* half-typed domain and change the form under someone mid-address. It also spent a rate-limit
* budget of 10/min per IP on people who had not finished typing an office behind one address
* could exhaust it without a single sign-in attempt.
*
* Applied HERE, after the `let identified` / `let ssoOnlyDomain` declarations above. Called any
* earlier it would throw on the temporal dead zone, which on this page means a login form that
* never renders.
*/
if (isSetup) identified = true; // first-run setup needs both fields at once
applyFormState();
/*
* Completing an SSO login.
*
* The callback no longer hands the session token back in the URL that was a login-CSRF hole,
* because a crafted link could install an ATTACKER'S token and quietly sign the victim into their
* account. The server now leaves it in a one-shot httpOnly cookie and we exchange it here, which
* a link cannot forge.
*
* Wrapped in an async IIFE because setupHandlers() is not async; `await` at this level is a
* SyntaxError that takes the whole module graph down with it, since app.js imports this file
* statically and there is no bundler to catch it first.
*/
const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || ''));
const ssoReturning = ssoParams.get('sso') === '1';
const ssoError = ssoParams.get('sso_error');
if (ssoReturning || ssoError) {
// Keep any real query string; only the hash carried the SSO markers.
history.replaceState(null, '', window.location.pathname + window.location.search + '#/login');
}
if (ssoReturning) {
(async () => {
try {
const res = await fetch('/api/auth/sso/claim', { method: 'POST' });
if (!res.ok) throw new Error('claim rejected');
const data = await res.json();
onAuthSuccess(data);
} catch {
showToast(t('auth.sso_failed'), 'error');
}
});
})();
} else if (ssoError) {
// Every code the callback can emit has a message; an unknown one still says something true
// rather than failing silently, which is how the previous implementation behaved on every click.
const known = ['expired', 'bad_state', 'no_code', 'no_email', 'email_unverified',
'verification_failed', 'provider_refused', 'provider_unavailable', 'unknown_provider',
'registration_disabled', 'account_exists_local', 'subject_mismatch', 'server_error',
'domain_not_allowed', 'account_exists_other_provider', 'sso_required'];
const key = known.includes(ssoError) ? `auth.sso_err_${ssoError}` : 'auth.sso_failed';
showToast(t(key), 'error');
}
}

View file

@ -219,7 +219,10 @@ export function render(container) {
await fetch(`/api/assignments/device/${pairedDeviceId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content_id: content.id, duration_sec: 10 })
// #237: no duration_sec — onboarding never asked the operator for one, and a
// hardcoded 10 cut their very first video off mid-play. Omitting it lets the
// server default to the clip's own length (still 10s for a photo).
body: JSON.stringify({ content_id: content.id })
});
} catch {}
}

View file

@ -2,6 +2,7 @@ import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js';
function formatDate(ts) {
if (!ts) return '--';
@ -210,9 +211,13 @@ async function renderDetail(container, playlistId) {
// /api/playlists/:id/preview-payload and renders with its unmodified renderer, so the
// preview is byte-identical to what a device shows. Orientation toggle just reloads
// the iframe with &orientation; the server passes it through.
// #238: Portrait here had the same fault as the device preview — the iframe was given the
// as-displayed 9/16 shape AND the player rotated inside it, so the portrait toggle showed sideways
// content. The stage is the panel's face; the iframe is its landscape framebuffer, turned back by
// the stand-in for the wall mount.
function showPlaylistPreview(playlist) {
let orientation = 'landscape';
const aspect = () => (orientation.startsWith('portrait') ? '9 / 16' : '16 / 9');
const aspect = () => displayAspectRatio(orientation);
const frameSrc = () => `/player?preview=1&playlist=${encodeURIComponent(playlist.id)}&orientation=${orientation}&t=${Date.now()}`;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
@ -227,28 +232,89 @@ function showPlaylistPreview(playlist) {
</div>
</div>
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
<iframe id="pvpFrame" style="height:78vh;max-width:92vw;aspect-ratio:${aspect()};border:0;background:#000" src="${frameSrc()}"></iframe>
<!-- The stage is the sized box; the frame inside it is rotated to the panel's own shape
(#238), so a portrait preview is no longer the double rotation it used to be.
72vh rather than 78: the modal caps at 92vh with overflow:hidden, and the header plus
the transport row cost ~12vh at 78 the skip buttons fell off the bottom. -->
<div id="pvpStage" style="height:72vh;max-width:92vw;aspect-ratio:${aspect()};background:#000">
<iframe id="pvpFrame" style="border:0;background:#000" src="${frameSrc()}"></iframe>
</div>
</div>
<div style="display:flex;justify-content:center;align-items:center;gap:12px;padding:10px 16px;border-top:1px solid var(--border)">
<button class="btn btn-secondary btn-sm" id="pvpPrev" disabled>&#8249; ${t('playlist.preview_prev')}</button>
<span id="pvpPosition" style="color:var(--text-muted);font-size:13px;min-width:110px;text-align:center">&nbsp;</span>
<button class="btn btn-secondary btn-sm" id="pvpNext" disabled>${t('playlist.preview_next')} &#8250;</button>
</div>
</div>`;
document.body.appendChild(overlay);
const stage = overlay.querySelector('#pvpStage');
const frame = overlay.querySelector('#pvpFrame');
frameDeviceOutput(stage, frame, orientation);
const btnL = overlay.querySelector('#pvpLandscape');
const btnP = overlay.querySelector('#pvpPortrait');
const btnPrev = overlay.querySelector('#pvpPrev');
const btnNext = overlay.querySelector('#pvpNext');
const position = overlay.querySelector('#pvpPosition');
// #239: skip/next. The preview already IS the real player in device-free preview mode, so the
// control is a message to that one iframe rather than a second copy of the playback logic.
// Addressing frame.contentWindow (not a broadcast) and pinning targetOrigin to our own origin is
// what keeps this off any real screen: a live display holds a socket to the server and is not
// reachable from this page at all, and the preview player itself ignores the message unless it
// booted with ?preview=1.
const send = (action) => {
try { frame.contentWindow?.postMessage({ source: 'screentinker-preview', action }, window.location.origin); } catch (e) {}
};
const onPlayerMessage = (ev) => {
if (ev.origin !== window.location.origin) return;
if (ev.source !== frame.contentWindow) return; // ignore any other frame on the page
const d = ev.data;
if (!d || d.source !== 'screentinker-player' || d.type !== 'preview:state') return;
// A multi-zone playlist plays all zones at once, so there is no single item to step through —
// showing a counter there would be a lie and the buttons would appear dead.
if (d.zoned || !d.total) {
btnPrev.disabled = btnNext.disabled = true;
position.textContent = d.zoned ? t('playlist.preview_zoned') : '';
return;
}
btnPrev.disabled = btnNext.disabled = false;
position.textContent = t('playlist.preview_position', { current: (d.index >= 0 ? d.index : 0) + 1, total: d.total });
};
window.addEventListener('message', onPlayerMessage);
// The player posts its state as soon as it has content, but an orientation reload restarts it —
// ask again on every load so the counter can never be left stale from the previous run.
frame.addEventListener('load', () => send('sync'));
const setOrientation = (o) => {
orientation = o;
frame.style.aspectRatio = aspect();
// The stage carries the aspect; the frame is rotated inside it (#238).
stage.style.aspectRatio = aspect();
frameDeviceOutput(stage, frame, orientation);
btnPrev.disabled = btnNext.disabled = true; // reloading: no item until the player says so
position.textContent = '';
frame.src = frameSrc();
btnL.className = 'btn btn-sm ' + (o === 'landscape' ? 'btn-primary' : 'btn-secondary');
btnP.className = 'btn btn-sm ' + (o.startsWith('portrait') ? 'btn-primary' : 'btn-secondary');
};
btnL.onclick = () => setOrientation('landscape');
btnP.onclick = () => setOrientation('portrait');
const close = () => overlay.remove();
btnPrev.onclick = () => send('prev');
btnNext.onclick = () => send('next');
// Listeners are on window/document, so they outlive the overlay unless close() takes them with
// it — a leaked keydown handler would keep firing at a closed preview.
const close = () => {
overlay.remove();
window.removeEventListener('message', onPlayerMessage);
document.removeEventListener('keydown', onKey);
};
function onKey(ev) {
if (ev.key === 'Escape') close();
else if (ev.key === 'ArrowRight') send('next');
else if (ev.key === 'ArrowLeft') send('prev');
}
overlay.querySelector('#pvpClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
document.addEventListener('keydown', function esc(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
document.addEventListener('keydown', onKey);
}
/*
@ -735,7 +801,12 @@ async function showAddItemModal(playlistId, opts = {}) {
list.innerHTML = filtered.map(item => {
const isWidget = activeTab === 'widgets';
const name = item.filename || item.name || t('common.unknown');
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : (item.mime_type || '');
// #237: the server gives a video item the clip's own length instead of the 10s default.
// Show that length here so the duration the item lands with is something the operator
// saw coming, rather than a number that appears in the list after the fact.
const clipSec = !isWidget && Number(item.duration_sec) > 0 ? Math.ceil(item.duration_sec) : 0;
const clip = clipSec ? ` · ${Math.floor(clipSec / 60)}:${String(clipSec % 60).padStart(2, '0')}` : '';
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : ((item.mime_type || '') + clip);
const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null;
return `
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">

View file

@ -39,7 +39,7 @@ export async function render(container) {
<div class="form-group" style="margin:0"><label>${t('report.device')}</label>
<select id="reportDevice" class="input" style="width:200px;background:var(--bg-input)">
<option value="">${t('report.all_devices')}</option>
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
${devices.map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}
</select>
</div>
<div class="form-group" style="margin:0"><label>${t('report.start_date')}</label>
@ -182,10 +182,10 @@ function renderBarChart(containerId, data) {
const maxVal = Math.max(...data.map(d => d.value), 1);
container.innerHTML = data.map(d => `
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:0" title="${d.label}: ${d.value}">
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:0" title="${esc(d.label)}: ${esc(d.value)}">
<div style="font-size:9px;color:var(--text-muted);margin-bottom:2px;display:${d.value > 0 ? 'block' : 'none'}">${d.value}</div>
<div style="width:100%;max-width:20px;height:${Math.max(2, (d.value / maxVal) * 160)}px;background:var(--accent);border-radius:2px 2px 0 0;min-height:2px"></div>
<div style="font-size:8px;color:var(--text-muted);margin-top:4px;transform:rotate(-45deg);white-space:nowrap">${d.label}</div>
<div style="font-size:8px;color:var(--text-muted);margin-top:4px;transform:rotate(-45deg);white-space:nowrap">${esc(d.label)}</div>
</div>
`).join('');
}

View file

@ -16,6 +16,9 @@ export async function render(container) {
// admin is now just isPlatformAdmin. (Elevated capability otherwise comes from
// org/workspace membership, gated in the members views, not users.role.)
const isAdmin = isSuperAdmin;
const canManageOrgSecurity = isSuperAdmin || user.current_org_role === 'org_owner' || user.current_org_role === 'org_admin';
const widgetIsolationDisabled = !!user.current_organization?.widget_sandbox_isolation_disabled;
const WIDGET_ISOLATION_CONFIRM_PHRASE = 'I understand I am enabling a security hole';
// #83: the "About" version was hardcoded (showed v1.4.1 regardless of the build).
// Read it from the server (/api/version) the same way the admin view does.
@ -59,6 +62,16 @@ export async function render(container) {
<p style="color:var(--text-muted);font-size:12px;margin-top:16px">${t('settings.sso_note', { provider: esc(user.auth_provider || 'SSO') })}</p>
`}
<!--
Sign-in method (#258). An account has exactly ONE credential: a password, or one
instance-wide provider. Linking deletes the password; unlinking requires a new one in the
same step, so the account is never briefly left with no way in. Populated by loadSsoLink().
-->
<div id="ssoLinkBlock" style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.signin_method')}</h4>
<p style="color:var(--text-muted);font-size:12px"></p>
</div>
<!-- Two-factor authentication (#100). Populated by load2FA() from /auth/totp/status. -->
<div id="twoFactorBlock" style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.2fa_title')}</h4>
@ -66,6 +79,35 @@ export async function render(container) {
</div>
</div>
<!-- Per-organization SSO. Hidden unless the signed-in user administers an organization: this
is the most security-relevant setting a tenant has, so it is not shown to members who
cannot change it. Instance-wide providers are the operator's business and are configured
by environment, not here. -->
<div class="settings-section" id="ssoCard" style="display:none">
<h3>${t('sso.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('sso.blurb')}</p>
<div id="ssoList"></div>
<details id="ssoAddDetails" style="margin-top:12px">
<summary style="cursor:pointer;font-size:13px">${t('sso.add')}</summary>
<div style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<div class="form-group"><label>${t('sso.f_name')}</label>
<input type="text" id="ssoName" class="input" placeholder="Acme SSO"></div>
<div class="form-group"><label>${t('sso.f_issuer')}</label>
<input type="url" id="ssoIssuer" class="input" placeholder="https://login.example.com">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_issuer_hint')}</div></div>
<div class="form-group"><label>${t('sso.f_client_id')}</label>
<input type="text" id="ssoClientId" class="input"></div>
<div class="form-group"><label>${t('sso.f_client_secret')}</label>
<input type="password" id="ssoClientSecret" class="input" autocomplete="new-password">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_client_secret_hint')}</div></div>
<div class="form-group"><label>${t('sso.f_domains')}</label>
<input type="text" id="ssoDomains" class="input" placeholder="acme.com, acme.co.uk">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_domains_hint')}</div></div>
<div><button class="btn btn-primary btn-sm" id="ssoCreateBtn">${t('sso.create')}</button></div>
</div>
</details>
</div>
<div class="settings-section">
<h3>${t('apitoken.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.desc')}</p>
@ -103,12 +145,37 @@ export async function render(container) {
<div id="tokenEditPanel" style="display:none"></div>
</div>
${canManageOrgSecurity ? `
<div class="settings-section">
<h3>Security</h3>
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div style="min-width:260px;flex:1">
<div style="font-weight:600">Widget sandbox isolation</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">
Keep widget code in a null-origin sandbox. Turning this off allows widget code to run with same-origin access.
</div>
</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;white-space:nowrap">
<input type="checkbox" id="widgetSandboxIsolationToggle" ${widgetIsolationDisabled ? '' : 'checked'}>
<span>${widgetIsolationDisabled ? 'Isolation disabled' : 'Isolation enabled'}</span>
</label>
</div>
</div>
` : ''}
${isAdmin ? `
<div class="settings-section">
<h3>${t('settings.license')}</h3>
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
</div>
${isSuperAdmin ? `
<div class="settings-section" id="telemetrySection">
<h3>Install statistics</h3>
<div id="telemetryBody"><p style="color:var(--text-muted);font-size:13px">Loading</p></div>
</div>
` : ''}
${isSuperAdmin ? `<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('settings.platform_admin_link')} <a href="#/admin" style="color:var(--accent)">${t('nav.admin')}</a> ${t('settings.platform_admin_page_suffix')}</p>` : ''}
<div class="settings-section">
@ -215,6 +282,7 @@ export async function render(container) {
if (isAdmin) {
loadUsers();
loadWhiteLabel();
loadTelemetry();
// Support token generator
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
@ -312,7 +380,7 @@ export async function render(container) {
let html = t('settings.import.complete', { imported });
if (result.device_pairings?.length) {
html += `<br><br><strong>${t('settings.import.pairing_codes_title')}</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` +
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${esc(d.name)}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
`</table><br>${t('settings.import.pairing_codes_hint')}`;
}
html += `<br><br>${(result.notes || []).map(n => '&bull; ' + n).join('<br>')}`;
@ -476,6 +544,176 @@ export async function render(container) {
// ==================== Two-factor authentication (#100) ====================
// Drives the merged TOTP backend (/api/auth/totp/*). Re-renders #twoFactorBlock
// for each state: SSO note / disabled+enroll / recovery-codes / enabled+manage.
/*
* Sign-in method: password OR one instance-wide provider, never both.
*
* The warning on the link button is the whole UX: the local password is DELETED, not kept as a
* fallback, and someone who does not read that will think they gained a second way in. Unlink
* asks for the new password up front for the same reason the account must never sit between
* credentials.
*
* Only instance-wide providers appear. An organization's provider is chosen by a customer and
* must not be attachable to a platform account; the server refuses it too.
*/
/*
* Install statistics. Shows the ACTUAL payload rather than a description of it the whole
* proposition is "you can check instead of trusting us", and the code is public, so a sentence
* that didn't match the bytes would be found. Also shows what was last really sent.
*/
async function loadTelemetry() {
const box = document.getElementById('telemetryBody');
if (!box) return;
let info;
try { info = await api.adminGetTelemetry(); }
catch { box.innerHTML = `<p style="color:var(--text-muted);font-size:13px">Unavailable.</p>`; return; }
const on = info.state === 'on';
const sent = info.last_report
? `Last sent ${new Date(info.last_report.at * 1000).toLocaleString()}.`
: 'Nothing has been sent yet.';
// A blocked outbound connection is the normal failure on a self-hosted box, and it is
// otherwise invisible — the operator just sees nothing arriving. Name the failure and the
// host, so the fix is "allow this in the firewall" rather than "guess".
const failed = on && info.last_error;
const why = failed
? ({ network: 'the connection was refused or the address did not resolve',
timeout: 'the connection timed out' }[info.last_error.reason]
|| `the server replied ${esc(info.last_error.reason)}`)
: '';
box.innerHTML = `
<p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">
ScreenTinker can't see how widely it's deployed, because most installs are private by
design. Sharing lets us say how many screens are running nothing more.
</p>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<input type="checkbox" id="telemetryToggle" ${on ? 'checked' : ''}>
Share install statistics
</label>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:6px">
Everything that would be sent, in full:
</p>
<pre style="background:var(--bg-input,rgba(0,0,0,.2));padding:10px;border-radius:var(--radius);font-size:12px;overflow-x:auto;margin-bottom:8px">${esc(JSON.stringify(info.payload, null, 2))}</pre>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:${info.extra_endpoint ? '4' : '8'}px">
${on ? 'Sent once a day to' : 'When enabled, sent once a day to'}
<code style="font-size:11px">${esc(info.endpoint || '')}</code>. If this server's outbound
traffic is filtered, that address has to be allowed or the reports never arrive.
</p>
${info.extra_endpoint ? `
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">
A second copy also goes to your own collector at
<code style="font-size:11px">${esc(info.extra_endpoint)}</code>, configured on this server
with <code style="font-size:11px">TELEMETRY_EXTRA_ENDPOINT</code>. That is in addition to
the above, not instead of it turn the switch off if you want your own statistics without
sharing.
</p>` : ''}
${failed ? `
<p style="font-size:12px;color:var(--danger);margin-bottom:8px">
The last attempt (${esc(new Date(info.last_error.at * 1000).toLocaleString())}) did not get
through ${why}. Check that outbound HTTPS to that address is permitted.
</p>` : ''}
<p style="color:var(--text-muted);font-size:12px">
No names, addresses, content, or user details. The ID is random and identifies the install
only so repeat reports aren't counted twice. ${esc(sent)}
</p>
`;
document.getElementById('telemetryToggle')?.addEventListener('change', async (e) => {
const enabled = e.target.checked;
try {
// Turning it on sends immediately, so a blocked firewall is reported here and now rather
// than failing quietly tonight — say so plainly instead of a cheerful success toast.
const r = await api.adminSetTelemetry(enabled);
if (!enabled) showToast('Install statistics off', 'success');
else if (r.first_report && r.first_report.sent) showToast('Shared — thank you', 'success');
else showToast('Saved, but the first report did not get through — see below', 'error');
loadTelemetry();
} catch {
e.target.checked = !enabled;
showToast('Could not save that setting', 'error');
}
});
}
async function loadSsoLink() {
const block = document.getElementById('ssoLinkBlock');
if (!block) return;
const head = `<h4 style="font-size:14px;margin-bottom:8px">${t('settings.signin_method')}</h4>`;
const muted = 'color:var(--text-muted);font-size:12px';
const paint = (inner) => { block.innerHTML = head + inner; };
let me;
try { me = await api.getMe(); }
catch (e) { paint(`<p style="${muted}">${esc(e.message)}</p>`); return; }
let providers = [];
try {
const res = await fetch('/api/auth/providers');
if (res.ok) providers = (await res.json()).providers || [];
} catch { /* offline: fall through to the no-providers copy */ }
if (me.auth_provider && me.auth_provider !== 'local') {
const name = providers.find((p) => p.slug === me.auth_provider)?.name || me.auth_provider;
paint(`
<p style="${muted};margin-bottom:12px">${t('settings.signin_linked', { provider: esc(name) })}</p>
<div id="unlinkForm" style="display:none;margin-bottom:12px">
<p style="${muted};margin-bottom:8px">${t('settings.signin_unlink_desc')}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<div class="form-group"><label>${t('settings.new_password')}</label><input type="password" id="unlinkPw" class="input" autocomplete="new-password"></div>
<div class="form-group"><label>${t('settings.confirm_new_password')}</label><input type="password" id="unlinkPw2" class="input" autocomplete="new-password"></div>
</div>
<button class="btn btn-primary btn-sm" id="unlinkConfirmBtn">${t('settings.signin_unlink_confirm')}</button>
</div>
<button class="btn btn-secondary btn-sm" id="unlinkBtn">${t('settings.signin_unlink', { provider: esc(name) })}</button>
`);
document.getElementById('unlinkBtn').onclick = () => {
document.getElementById('unlinkForm').style.display = '';
document.getElementById('unlinkBtn').style.display = 'none';
document.getElementById('unlinkPw').focus();
};
document.getElementById('unlinkConfirmBtn').onclick = async () => {
const pw = document.getElementById('unlinkPw').value;
const pw2 = document.getElementById('unlinkPw2').value;
if (pw !== pw2) return showToast(t('settings.passwords_dont_match'), 'error');
try {
await api.ssoUnlink(pw);
showToast(t('settings.signin_unlinked_toast'), 'success');
loadSsoLink();
} catch (e) { showToast(e.message, 'error'); }
};
return;
}
if (!providers.length) {
paint(`<p style="${muted}">${t('settings.signin_password_only')}</p>`);
return;
}
paint(`
<p style="${muted};margin-bottom:12px">${t('settings.signin_password_now')}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${providers.map((p) => `<button class="btn btn-secondary btn-sm" data-link-slug="${esc(p.slug)}">${t('settings.signin_link', { provider: esc(p.name) })}</button>`).join('')}
</div>
`);
block.querySelectorAll('[data-link-slug]').forEach((btn) => {
btn.onclick = async () => {
const slug = btn.dataset.linkSlug;
const name = providers.find((p) => p.slug === slug)?.name || slug;
// Deliberately blunt: the password is destroyed, and that is the part people miss.
if (!window.confirm(t('settings.signin_link_warning', { provider: name }))) return;
/*
* Fetch the authorize URL, then navigate to it. NOT location.href straight at the start
* route: the session is a bearer token in localStorage, so a top-level navigation arrives
* with no Authorization header and is refused as anonymous.
*/
try {
const { url } = await api.ssoLinkStart(slug);
window.location.href = url;
} catch (e) { showToast(e.message, 'error'); }
};
});
}
async function load2FA() {
const block = document.getElementById('twoFactorBlock');
if (!block) return;
@ -610,6 +848,32 @@ export async function render(container) {
loadTokens();
load2FA();
loadSsoLink();
/*
* Report the outcome of a link round trip.
*
* The callback returns to #/settings rather than the login page an authenticated user bounced
* to a login screen to be told "that did not work" reads as having been signed out. Params are
* stripped afterwards so a refresh or a copied URL does not replay the message.
*/
(function reportLinkOutcome() {
const q = new URLSearchParams((location.hash.split('?')[1] || ''));
const linked = q.get('sso_linked');
const err = q.get('sso_error');
if (!linked && !err) return;
if (linked) {
showToast(t('settings.signin_linked_toast', { provider: linked }), 'success');
} else {
const known = ['link_email_mismatch', 'link_already_used', 'not_linkable', 'no_email',
'email_unverified', 'verification_failed', 'provider_unavailable', 'provider_refused',
'unknown_provider', 'expired', 'bad_state', 'no_code', 'server_error'];
const key = known.includes(err) ? `settings.signin_err_${err}` : 'auth.sso_failed';
showToast(t(key), 'error');
}
history.replaceState(null, '', location.pathname + location.search + '#/settings');
loadSsoLink();
}());
// #73: agency scope reveals a playlist picker (the token's allowlist). Loaded lazily once.
const tokScopeSel = document.getElementById('tokScope');
@ -634,6 +898,388 @@ export async function render(container) {
}
});
/* Per-organization SSO
*
* Only an org owner/admin sees this. The server enforces the same rule (and answers 404, not
* 403, so an outsider learns nothing) this just avoids showing a card the user cannot use.
*/
const orgId = user.current_organization?.id;
const canManageSso = orgId && ['org_owner', 'org_admin'].includes(user.current_org_role);
async function loadSso() {
const card = document.getElementById('ssoCard');
if (!card || !canManageSso) return;
card.style.display = '';
const listEl = document.getElementById('ssoList');
let providers = [];
try {
const res = await fetch(`/api/organizations/${orgId}/sso`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (!res.ok) throw new Error('load failed');
providers = (await res.json()).providers || [];
} catch {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.load_failed'))}</p>`;
return;
}
if (!providers.length) {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.none'))}</p>`;
return;
}
// Requiring SSO is a separate decision from having it, so it gets its own block rather than
// hiding inside a provider — an organization may have several providers and one answer.
let onlyState = null;
try {
const r = await fetch(`/api/organizations/${orgId}/sso-only`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (r.ok) onlyState = await r.json();
} catch { /* the providers still render; the toggle simply does not appear */ }
const origin = `${window.location.protocol}//${window.location.host}`;
listEl.innerHTML = providers.map((p) => `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
<div>
<strong>${esc(p.name)}</strong>
${p.enabled ? '' : `<span style="font-size:11px;color:var(--text-muted)"> — ${esc(t('sso.disabled'))}</span>`}
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(p.issuer)}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}</div>
${((p.domains || []).some((d) => !d.verified) || (p.domains || []).length === 0)
? `<div style="font-size:12px;color:var(--warning,#b45309);margin-top:2px">⚠️ ${esc(t('sso.unverified_warning'))}</div>`
: ''}
</div>
<!-- wrap, do not shrink-to-clip: at 375px this row ran to x=417 on a 375px viewport and
the page does not scroll horizontally, so "Remove" was simply unreachable. -->
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary btn-sm" data-sso-test="${esc(p.id)}">${esc(t('sso.test'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-edit="${esc(p.id)}">${esc(t('sso.edit'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-toggle="${esc(p.id)}" data-enabled="${p.enabled ? '1' : '0'}">
${esc(p.enabled ? t('sso.disable') : t('sso.enable'))}
</button>
<button class="btn btn-danger btn-sm" data-sso-delete="${esc(p.id)}">${esc(t('sso.delete'))}</button>
</div>
</div>
<!-- The admin has to paste this into their identity provider, and it must match character
for character, so it is shown rather than described. -->
<div style="margin-top:8px;font-size:12px">
<div style="color:var(--text-muted)">${esc(t('sso.callback_label'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px">${esc(origin + p.callback_url)}</code>
</div>
<!-- Editing is per provider, because an organization may have several (one per domain, or
one per identity provider after a merger) and they are configured independently. -->
<!-- Domain proof. A claimed domain routes NOBODY until DNS confirms the organization
controls it, so the state of each one is shown plainly rather than left to be inferred
from a login that silently does not work. -->
${(p.domains || []).length ? `
<div style="margin-top:10px;font-size:12px">
<div style="color:var(--text-muted);margin-bottom:4px">${esc(t('sso.domains_heading'))}</div>
${p.domains.map((d, di) => `
<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<div><strong>${esc(d.domain)}</strong>
${d.verified
? `<span style="color:var(--success,#15803d)"> — ${esc(t('sso.domain_verified'))}</span>`
: `<span style="color:var(--warning,#b45309)"> — ${esc(t('sso.domain_pending'))}</span>`}
</div>
${d.verified ? '' : `<button class="btn btn-secondary btn-sm" data-sso-verify="${esc(p.id)}" data-domain="${esc(d.domain)}" data-di="${di}">${esc(t('sso.verify_now'))}</button>`}
</div>
${d.verified ? '' : `
<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.dns_instructions'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px;margin-top:4px">${esc(d.record_name)} TXT ${esc(d.txt_value)}</code>
`}
<!-- ONE place for the outcome. The last failure is persisted server-side and was
rendered here, while the click handler wrote the live result into a second
element below it so retrying showed the identical sentence twice, in two
different colours. The handler replaces this element's text instead. -->
<div id="ssoVerify-${esc(p.id)}-${di}" style="margin-top:4px;color:var(--danger,#b91c1c)">${d.verified ? '' : esc(d.last_error || '')}</div>
</div>`).join('')}
</div>` : ''}
<div id="ssoTest-${esc(p.id)}" style="display:none;margin-top:8px;font-size:12px"></div>
<div id="ssoEdit-${esc(p.id)}" style="display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);display:none">
<div style="display:grid;gap:10px;max-width:560px">
<div class="form-group"><label>${esc(t('sso.f_name'))}</label>
<input type="text" class="input" data-f="name" value="${esc(p.name)}"></div>
<div class="form-group"><label>${esc(t('sso.f_issuer'))}</label>
<input type="url" class="input" data-f="issuer" value="${esc(p.issuer)}"></div>
<div class="form-group"><label>${esc(t('sso.f_client_id'))}</label>
<input type="text" class="input" data-f="client_id" value="${esc(p.client_id)}"></div>
<div class="form-group"><label>${esc(t('sso.f_client_secret'))}</label>
<input type="password" class="input" data-f="client_secret" autocomplete="new-password"
placeholder="${esc(p.has_client_secret ? t('sso.secret_set') : t('sso.secret_none'))}">
<!-- A secret can never be shown back: the API does not return it. Blank therefore means
"leave it alone" rather than "clear it", which is what stops a save from silently
wiping a working configuration. Clearing is a separate, explicit choice. -->
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(t('sso.secret_edit_hint'))}</div>
${p.has_client_secret ? `
<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-top:6px">
<input type="checkbox" data-f="clear_secret"> ${esc(t('sso.secret_clear'))}
</label>` : ''}
</div>
<div class="form-group"><label>${esc(t('sso.f_domains'))}</label>
<input type="text" class="input" data-f="email_domains" value="${esc(p.email_domains)}"></div>
<div style="display:flex;gap:6px">
<button class="btn btn-primary btn-sm" data-sso-save="${esc(p.id)}">${esc(t('sso.save'))}</button>
<button class="btn btn-secondary btn-sm" data-sso-cancel="${esc(p.id)}">${esc(t('sso.cancel'))}</button>
</div>
</div>
</div>
</div>`).join('');
listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => {
btn.addEventListener('click', async () => {
await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' });
});
});
/*
* Ask the server to look for the DNS record now. Pull-based on purpose: the admin has just
* edited DNS and wants an answer, and a failure has to say WHICH failure not published yet,
* published wrong, or the claim expired and the record has changed underneath them.
*/
if (onlyState) {
const pend = onlyState.pending_removal_request;
const box = document.createElement('div');
box.style.cssText = 'border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-top:4px';
box.innerHTML = `
<div style="font-weight:600;margin-bottom:4px">${esc(t('sso.only_heading'))}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${esc(t('sso.only_help'))}</div>
${onlyState.sso_only ? `
<div style="font-size:13px;margin-bottom:8px"> ${esc(t('sso.only_on'))}</div>
${pend
? `<div style="font-size:12px;color:var(--warning,#b45309)">⏳ ${esc(t('sso.only_pending'))}</div>
<button class="btn btn-secondary btn-sm" id="ssoOnlyCancel" data-req="${esc(pend.id)}" style="margin-top:6px">${esc(t('sso.only_cancel'))}</button>`
: `<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">${esc(t('sso.only_remove_help'))}</div>
<button class="btn btn-secondary btn-sm" id="ssoOnlyRequest">${esc(t('sso.only_request'))}</button>`}
` : `
<div style="font-size:13px;margin-bottom:8px">${esc(t('sso.only_off'))}</div>
${onlyState.verified_domains
? `<button class="btn btn-secondary btn-sm" id="ssoOnlyEnable">${esc(t('sso.only_enable'))}</button>`
: `<div style="font-size:12px;color:var(--warning,#b45309)">⚠️ ${esc(t('sso.only_needs_domain'))}</div>`}
`}`;
listEl.appendChild(box);
const post = async (url, body, method = 'POST') => {
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json().catch(() => ({}));
if (!r.ok) { showToast(j.error || t('sso.only_failed'), 'error'); return null; }
return j;
};
const enableBtn = box.querySelector('#ssoOnlyEnable');
if (enableBtn) enableBtn.addEventListener('click', async () => {
// Confirmed, because it removes the only way in for everyone at these domains, and the way
// back needs the operator rather than this button.
if (!window.confirm(t('sso.only_confirm'))) return;
const r = await post(`/api/organizations/${orgId}/sso-only`);
if (r) {
showToast(t('sso.only_on'), 'success');
/*
* Name the people who just lost their only way in. The server reports them precisely so
* the admin finds out HERE rather than from a support ticket and it was being thrown
* away, which made the whole warning pointless.
*/
const stranded = r.stranded_members || [];
if (stranded.length) {
window.alert(t('sso.only_stranded', { list: stranded.join('\n') }));
}
await loadSso();
}
});
const reqBtn = box.querySelector('#ssoOnlyRequest');
if (reqBtn) reqBtn.addEventListener('click', async () => {
const reason = window.prompt(t('sso.only_reason_prompt')) || '';
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request`, { reason });
if (r) { showToast(t('sso.only_requested'), 'success'); await loadSso(); }
});
const cancelBtn = box.querySelector('#ssoOnlyCancel');
if (cancelBtn) cancelBtn.addEventListener('click', async () => {
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request/${cancelBtn.dataset.req}`, null, 'DELETE');
if (r) { showToast(t('sso.only_cancelled'), 'success'); await loadSso(); }
});
}
listEl.querySelectorAll('[data-sso-verify]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoVerify;
const domain = btn.dataset.domain;
// Indexed, not derived from the domain: `a.b.test` and `a-b.test` both slugify to
// `a-b-test`, and getElementById would put one domain's answer in the other's box.
const out = document.getElementById(`ssoVerify-${id}-${btn.dataset.di}`);
btn.disabled = true;
if (out) { out.style.color = 'var(--text-muted)'; out.textContent = t('sso.verifying'); }
try {
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/domains/${encodeURIComponent(domain)}/verify`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const body = await res.json().catch(() => ({}));
if (body.ok) {
showToast(t('sso.domain_verified_toast', { domain }), 'success');
await loadSso(); // re-render: the domain now routes, and the card must say so
return;
}
// An expired claim has already been reissued server-side, so the records on screen are
// stale — reload rather than leaving the admin publishing a value that no longer matches.
if (body.expired) {
showToast(body.error || t('sso.verify_failed'), 'error');
await loadSso();
return;
}
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = body.error || t('sso.verify_failed'); }
} catch {
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = t('sso.verify_failed'); }
} finally {
btn.disabled = false;
}
});
});
listEl.querySelectorAll('[data-sso-test]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoTest;
const out = document.getElementById(`ssoTest-${id}`);
if (!out) return;
out.style.display = '';
out.textContent = t('sso.testing');
try {
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/test`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const data = await res.json();
if (!res.ok) { out.textContent = data.error || t('sso.test_failed'); return; }
/*
* Literal keys, never a key built by concatenating a check name. Doing that defeats the
* check in server/test/i18n-keys-exist.js that every key an operator can see is
* translated and a check name the UI does not know would render as raw key text. The
* fallback keeps an unknown one readable instead.
*/
const CHECK_LABELS = {
discovery: t('sso.check_discovery'),
endpoints: t('sso.check_endpoints'),
signing_keys: t('sso.check_signing_keys'),
};
const rows = (data.checks || []).map((c) => `
<div>${c.ok ? '✅' : '❌'} ${esc(CHECK_LABELS[c.name] || c.name)} <span style="color:var(--text-muted)">${esc(c.detail || '')}</span></div>`).join('');
/*
* The caveat is shown on SUCCESS, not tucked away. Discovery and keys prove the provider
* exists and that we could verify a token it signs they say nothing about whether the
* client id, the secret, or the redirect URI registration are right. A green tick that
* implied "SSO works" would send an admin away from the one thing still to check.
*/
out.innerHTML = rows + (data.ok
? `<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.test_caveat'))}</div>`
: '');
} catch {
out.textContent = t('sso.test_failed');
}
});
});
listEl.querySelectorAll('[data-sso-edit]').forEach((btn) => {
btn.addEventListener('click', () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoEdit}`);
if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
});
});
listEl.querySelectorAll('[data-sso-cancel]').forEach((btn) => {
btn.addEventListener('click', () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoCancel}`);
if (panel) panel.style.display = 'none';
});
});
listEl.querySelectorAll('[data-sso-save]').forEach((btn) => {
btn.addEventListener('click', async () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoSave}`);
if (!panel) return;
const val = (f) => panel.querySelector(`[data-f="${f}"]`)?.value?.trim() ?? '';
const body = {
name: val('name'),
issuer: val('issuer'),
client_id: val('client_id'),
email_domains: val('email_domains'),
};
/*
* Three states, and only these three:
* typed a value -> replace the secret
* ticked "remove" -> send '' so the server clears it
* left blank, unticked -> send NOTHING, so the stored secret survives
* Sending '' on every save is the bug this shape exists to avoid.
*/
const typed = panel.querySelector('[data-f="client_secret"]')?.value || '';
const clearing = panel.querySelector('[data-f="clear_secret"]')?.checked;
if (typed) body.client_secret = typed;
else if (clearing) body.client_secret = '';
if (!body.name || !body.issuer || !body.client_id) {
showToast(t('sso.missing_fields'), 'error');
return;
}
await ssoRequest('PUT', `/${btn.dataset.ssoSave}`, body);
});
});
listEl.querySelectorAll('[data-sso-delete]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm(t('sso.confirm_delete'))) return;
await ssoRequest('DELETE', `/${btn.dataset.ssoDelete}`);
});
});
}
async function ssoRequest(method, path = '', body) {
try {
const res = await fetch(`/api/organizations/${orgId}/sso${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
// The server's message is the useful one here — a bad issuer or a domain already claimed by
// another organization both say exactly what went wrong, and a generic failure would not.
if (!res.ok) { showToast(data.error || t('sso.save_failed'), 'error'); return false; }
// "Saved" for a DELETE read as though nothing had been destroyed.
showToast(t(method === 'DELETE' ? 'sso.removed' : 'sso.saved'), 'success');
await loadSso();
return true;
} catch {
showToast(t('sso.save_failed'), 'error');
return false;
}
}
document.getElementById('ssoCreateBtn')?.addEventListener('click', async () => {
const payload = {
name: document.getElementById('ssoName').value.trim(),
issuer: document.getElementById('ssoIssuer').value.trim(),
client_id: document.getElementById('ssoClientId').value.trim(),
client_secret: document.getElementById('ssoClientSecret').value,
email_domains: document.getElementById('ssoDomains').value.trim(),
};
if (!payload.name || !payload.issuer || !payload.client_id) {
showToast(t('sso.missing_fields'), 'error');
return;
}
if (await ssoRequest('POST', '', payload)) {
['ssoName', 'ssoIssuer', 'ssoClientId', 'ssoClientSecret', 'ssoDomains']
.forEach((id) => { document.getElementById(id).value = ''; });
document.getElementById('ssoAddDetails').open = false;
}
});
loadSso();
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
const name = document.getElementById('tokName').value.trim();
const scope = document.getElementById('tokScope').value;
@ -737,6 +1383,117 @@ export async function render(container) {
btn.disabled = false;
}
});
document.getElementById('widgetSandboxIsolationToggle')?.addEventListener('change', async (e) => {
const checkbox = e.currentTarget;
const shouldEnableIsolation = !!checkbox.checked;
const workspaceId = user.current_workspace_id;
if (!workspaceId) {
checkbox.checked = !shouldEnableIsolation;
showToast('No active workspace', 'error');
return;
}
if (!shouldEnableIsolation) {
const confirmed = await openWidgetSandboxDisableConfirmModal(WIDGET_ISOLATION_CONFIRM_PHRASE);
if (!confirmed) {
checkbox.checked = true;
return;
}
try {
await api.updateWorkspaceSecuritySettings(workspaceId, {
widgetSandboxIsolationDisabled: true,
confirmationPhrase: WIDGET_ISOLATION_CONFIRM_PHRASE,
});
const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 1 } };
localStorage.setItem('user', JSON.stringify(nextUser));
showToast('Widget sandbox isolation disabled', 'success');
} catch (err) {
checkbox.checked = true;
showToast(err.message, 'error');
}
return;
}
try {
await api.updateWorkspaceSecuritySettings(workspaceId, { widgetSandboxIsolationDisabled: false });
const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 0 } };
localStorage.setItem('user', JSON.stringify(nextUser));
showToast('Widget sandbox isolation enabled', 'success');
} catch (err) {
checkbox.checked = false;
showToast(err.message, 'error');
}
});
}
function openWidgetSandboxDisableConfirmModal(confirmationPhrase) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal" style="width:min(760px,96vw)">
<div class="modal-header"><h3>Disable widget sandbox isolation for this organization</h3></div>
<div class="modal-body" style="white-space:pre-wrap;line-height:1.45">
Widget HTML currently runs in a null-origin sandbox. That means widget code
cannot read your session, your cookies, or anything else stored by
ScreenTinker in this browser.
Turning this off re-enables allow-same-origin. Widget HTML will then run with
the same privileges as ScreenTinker itself. Any script in any widget in this
organization will be able to:
- Read the device token of every display that shows the widget, and act as
that display against the ScreenTinker API
- Read the session token of any logged-in user who opens a display in their
own browser
- Call the ScreenTinker API as that user, including admin actions
- Read and modify content on every other display in this organization
- Silently exfiltrate all of the above to any server it likes
The widget editor's Preview is NOT affected: it renders inside the dashboard,
where your session lives, so it stays isolated whatever this setting says. A
widget may therefore behave differently in Preview than on a display.
Because allow-scripts is also required for widgets to function, a widget can
remove its own sandbox entirely once same-origin is granted. There is no
partial protection left after this point.
Only enable this if every widget source in this organization is code you
wrote, or code from a party you would trust with your admin password. A single
compromised third-party embed, CDN, or ad tag is enough.
This setting applies to ALL widgets in this organization and cannot be scoped
per display.
<div class="form-group" style="margin-top:16px">
<label for="widgetSandboxConfirmInput">Type the phrase below to confirm:</label>
<div style="margin:6px 0 8px;font-weight:600">${esc(confirmationPhrase)}</div>
<input id="widgetSandboxConfirmInput" type="text" class="input" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="widgetSandboxConfirmCancel">Cancel</button>
<button class="btn btn-danger" id="widgetSandboxConfirmSubmit" disabled>Disable isolation</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#widgetSandboxConfirmInput');
const submit = overlay.querySelector('#widgetSandboxConfirmSubmit');
const close = (ok) => {
overlay.remove();
resolve(ok);
};
const updateEnabled = () => {
submit.disabled = input.value.trim() !== confirmationPhrase;
};
input.addEventListener('input', updateEnabled);
overlay.querySelector('#widgetSandboxConfirmCancel').addEventListener('click', () => close(false));
submit.addEventListener('click', () => close(true));
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(false); });
});
}
async function loadWhiteLabel() {
@ -830,25 +1587,30 @@ async function loadUsers() {
</thead>
<tbody>
${users.map(u => `
<tr style="border-bottom:1px solid var(--border)" data-user-id="${u.id}">
<!-- ESCAPED. A SECOND copy of the platform users table lives here, rendered from the
same endpoint as the one in views/admin.js. Escaping only that one left this whole
table wide open, including a raw text node for the email - and an org or workspace
admin can choose an email, so this executed in the platform admin's session. When
you touch one of these tables, touch both. -->
<tr style="border-bottom:1px solid var(--border)" data-user-id="${esc(u.id)}">
<td style="padding:10px 12px">
<div style="font-weight:500">${u.name || u.email}</div>
<div style="font-size:11px;color:var(--text-muted)">${u.email}</div>
<div style="font-weight:500">${esc(u.name || u.email)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(u.email)}</div>
</td>
<td style="padding:10px 12px">
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span>
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${esc(u.auth_provider)}</span>
</td>
<td style="padding:10px 12px">
<span style="color:${isPlatformAdmin(u) ? 'var(--accent)' : 'var(--text-secondary)'}">${u.role}</span>
<span style="color:${isPlatformAdmin(u) ? 'var(--accent)' : 'var(--text-secondary)'}">${esc(u.role)}</span>
</td>
<td style="padding:10px 12px">
<select class="input plan-select" data-user-id="${u.id}" style="padding:4px 8px;font-size:12px;width:auto">
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
<select class="input plan-select" data-user-id="${esc(u.id)}" style="padding:4px 8px;font-size:12px;width:auto">
${plans.map(p => `<option value="${esc(p.id)}" ${u.plan_id === p.id ? 'selected' : ''}>${esc(p.display_name)}</option>`).join('')}
</select>
</td>
<td style="padding:10px 12px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${esc(u.id)}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
</td>
</tr>
`).join('')}

View file

@ -1,6 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { esc, livenessBadge } from '../utils.js';
import { t } from '../i18n.js';
const API = (url, opts = {}) => fetch('/api' + url, {
@ -17,6 +18,25 @@ const CANVAS_MIN_W = 1200;
const CANVAS_MIN_H = 700;
const CANVAS_PADDING = 200; // extra room beyond bounding box, in canvas units
// #236: how far a panel's own image has to be turned to come out upright on the wall — i.e. how
// far the panel itself is hung the other way. Degrees CLOCKWISE, matching the per-device
// orientation setting; the render rule lives in server/lib/wall-geometry.js.
//
// Before this existed the canvas was secretly FRAMEBUFFER space, so a customer with two portrait
// panels side by side had to stack them vertically here and pre-rotate every video. The canvas is
// now what it always looked like: the wall as the audience sees it.
const WALL_ROTATIONS = [0, 90, 180, 270];
const ROTATION_LABELS = { 0: 'Normal (0°)', 90: 'Turned left (90°)', 180: 'Upside down (180°)', 270: 'Turned right (270°)' };
// A panel already configured portrait is already hung sideways; carry that across so the operator
// doesn't have to say the same thing twice (and so the tile lands the right shape first time).
const ORIENTATION_TO_ROTATION = { 'landscape': 0, 'portrait': 90, 'landscape-flipped': 180, 'portrait-flipped': 270 };
// Mirrors rotatedFootprint() in server/lib/wall-geometry.js — kept tiny and local because the
// dashboard has no import path to the server lib.
function footprintFor(renderW, renderH, rotation) {
return (rotation === 90 || rotation === 270) ? { w: renderH, h: renderW } : { w: renderW, h: renderH };
}
export async function render(container) {
const hash = window.location.hash;
if (hash.startsWith('#/wall/')) {
@ -67,7 +87,7 @@ async function renderList(container) {
</div>
</div>
<div class="content-item-body">
<div class="content-item-name">${w.name}</div>
<div class="content-item-name">${esc(w.name)}</div>
<div class="content-item-size">${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}</div>
</div>
</div>
@ -105,6 +125,14 @@ async function renderWallEditor(container, wallId) {
if (d && d.render_width > 0 && d.render_height > 0) return { w: d.render_width, h: d.render_height };
return { w: DEFAULT_SCREEN_W, h: DEFAULT_SCREEN_H };
};
// #236: the tile is the panel's footprint ON THE WALL, so a sideways-hung panel is a tall tile.
const footprintOnWall = (id, rotation) => {
const r = renderSizeFor(id);
return footprintFor(r.w, r.h, rotation);
};
// A device already set to portrait is already hung sideways — start it there rather than making
// the operator discover the rotation control after the wall comes out wrong.
const defaultRotationFor = (id) => ORIENTATION_TO_ROTATION[deviceById(id)?.orientation] || 0;
// When the panel's physical resolution differs from what it renders (rotated mount:
// the box reports 800x1332 but draws 1332x800), the tile size can look "wrong". Return a
// short note making explicit that the tile is sized to the RENDER surface, not the panel.
@ -127,8 +155,10 @@ async function renderWallEditor(container, wallId) {
rotation: d.rotation || 0,
x: d.canvas_x ?? (d.grid_col * (baseW + bezelH)),
y: d.canvas_y ?? (d.grid_row * (baseH + bezelV)),
w: d.canvas_width ?? renderSizeFor(d.device_id).w,
h: d.canvas_height ?? renderSizeFor(d.device_id).h,
// Backfill sizes as the panel's footprint at its saved rotation. Every wall in the field is
// rotation 0, where this is exactly the old expression — so nothing existing moves.
w: d.canvas_width ?? footprintOnWall(d.device_id, d.rotation || 0).w,
h: d.canvas_height ?? footprintOnWall(d.device_id, d.rotation || 0).h,
}));
// Default player covers the bounding box of all screens; if there are no
@ -224,6 +254,19 @@ async function renderWallEditor(container, wallId) {
</select>
<button class="btn btn-primary btn-sm" id="setPlaylistBtn" style="margin-left:8px">${t('wall.set_playlist')}</button>
</div>
<!-- #235: a wall used to swallow its members whole joining one removed the device's card
from Displays, so an operator could not see that one panel of a four-panel wall had
dropped off, and the only way to check a single screen was to pull it out of the wall
(which re-syncs the live wall) and put it back. Panel state and a way through to the
device live here now. -->
<div style="margin-top:20px">
<h3 style="font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:10px">
Panels
<span id="wallPanelSummary" style="font-size:12px;font-weight:400;color:var(--text-muted)"></span>
</h3>
<div id="wallPanelStatus"></div>
</div>
</div>
<div style="width:260px;flex-shrink:0">
@ -234,9 +277,13 @@ async function renderWallEditor(container, wallId) {
<div class="info-card" style="margin-top:14px;padding:10px;font-size:12px;line-height:1.55">
<strong style="font-size:12px">How it works</strong>
<ul style="margin:6px 0 0 14px;padding:0;color:var(--text-secondary)">
<li>This canvas is the wall <strong>as the audience sees it</strong>. Arrange the
rectangles to match the physical layout.</li>
<li>Each rectangle is a physical screen.</li>
<li>The blue dashed rectangle is the player window content plays inside this rect.</li>
<li>Each screen shows only the part of the player that overlaps it.</li>
<li>Panel hung sideways? Select it and set <em>How this panel is mounted</em> the
tile turns to match and the content is rotated for you.</li>
<li>Drag corners to resize, drag the body to move.</li>
</ul>
</div>
@ -252,6 +299,7 @@ async function renderWallEditor(container, wallId) {
for (const s of screens) canvas.appendChild(renderScreenEl(s));
updateOverlapsAll();
renderSidebar();
renderPanelStatus();
applySelectionClasses();
renderSelectionPanel();
applyTransform();
@ -287,12 +335,43 @@ async function renderWallEditor(container, wallId) {
<label>W</label><input type="number" data-field="w" value="${Math.round(rect.w)}" step="1" min="40">
<label>H</label><input type="number" data-field="h" value="${Math.round(rect.h)}" step="1" min="24">
</div>
${isPlayer ? '' : `
<div style="margin-top:10px">
<label style="font-size:11px;color:var(--text-muted);display:block;margin-bottom:3px">How this panel is mounted</label>
<select id="screenRotation" class="input" style="width:100%;font-size:12px;background:var(--bg-input)">
${WALL_ROTATIONS.map(r => `<option value="${r}" ${(rect.rotation || 0) === r ? 'selected' : ''}>${ROTATION_LABELS[r]}</option>`).join('')}
</select>
<p style="margin:5px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
Lay the canvas out to match the <strong>physical</strong> wall and set this per panel
content is rotated for you, so portrait walls don't need pre-rotated video.
While a panel is in a wall this replaces its own Orientation setting.
</p>
</div>`}
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
Arrow keys nudge by 1px. Hold <kbd>Shift</kbd> for 10px.
Click outside any rect to deselect.
</p>
</div>
`;
panel.querySelector('#screenRotation')?.addEventListener('change', (ev) => {
const r = getSelectedRect();
if (!r) return;
const next = parseInt(ev.target.value, 10) || 0;
const prev = r.rotation || 0;
r.rotation = next;
// Turning a panel by a quarter turn changes its footprint on the wall. Swap the tile about
// its own CENTRE so it turns in place — resizing from the corner would shove every
// neighbouring tile out of alignment and undo the operator's careful placement.
const wasQuarter = (prev === 90 || prev === 270);
const isQuarter = (next === 90 || next === 270);
if (wasQuarter !== isQuarter) {
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
const w = r.h, h = r.w;
r.w = w; r.h = h; r.x = cx - w / 2; r.y = cy - h / 2;
}
markDirty();
renderAll();
});
panel.querySelector('#deselectBtn').addEventListener('click', () => {
selected = null;
applySelectionClasses();
@ -384,6 +463,7 @@ async function renderWallEditor(container, wallId) {
<div class="wall-screen-meta">
<span class="status-dot ${s.device_status}" style="display:inline-block"></span>
<span style="font-size:10px;color:var(--text-muted)">${Math.round(s.w)}×${Math.round(s.h)}</span>
${(s.rotation || 0) !== 0 ? `<span class="wall-screen-rot" title="${esc(ROTATION_LABELS[s.rotation])}" style="font-size:10px;color:var(--accent);margin-left:4px">⟳${s.rotation}°</span>` : ''}
</div>
${renderNoteFor(s.device_id) ? `<div class="wall-screen-rendernote" style="font-size:9px;color:var(--warning,#e0a800);margin-top:2px;line-height:1.2">${esc(renderNoteFor(s.device_id))}</div>` : ''}
</div>
@ -453,6 +533,92 @@ async function renderWallEditor(container, wallId) {
});
}
// #235: per-panel state for the wall, with a way through to the device itself.
// Live socket updates are merged over the fetched rows so a panel that drops off mid-session
// turns red here without a reload — "is the wall actually up?" has to be answerable from the
// dashboard, because today it means sending someone to look at it.
const liveStatus = {};
function panelLiveness(deviceId) {
const d = deviceById(deviceId) || {};
const live = liveStatus[deviceId];
return livenessBadge({ ...d, ...(live || {}) }, { short: true });
}
function renderPanelStatus() {
const host = document.getElementById('wallPanelStatus');
if (!host) return;
const summary = document.getElementById('wallPanelSummary');
if (screens.length === 0) {
host.innerHTML = `<p style="color:var(--text-muted);font-size:12px;margin:0">No panels on this wall yet — drag displays onto the canvas.</p>`;
if (summary) summary.textContent = '';
return;
}
const badges = screens.map(s => ({ s, b: panelLiveness(s.device_id) }));
const down = badges.filter(x => x.b.state !== 'online').length;
if (summary) {
summary.textContent = down === 0
? `${screens.length} panel${screens.length === 1 ? '' : 's'}, all online`
: `${down} of ${screens.length} not online`;
summary.style.color = down === 0 ? 'var(--success)' : 'var(--danger, #e5484d)';
}
host.innerHTML = `
<div class="wall-panel-list" style="display:flex;flex-direction:column;gap:6px">
${badges.map(({ s, b }) => {
const d = deviceById(s.device_id) || {};
const meta = [
d.app_version ? `v${esc(d.app_version)}` : '',
(s.rotation || 0) !== 0 ? esc(ROTATION_LABELS[s.rotation]) : '',
].filter(Boolean).join(' · ');
return `
<div class="playlist-item" data-device-id="${esc(s.device_id)}" style="display:flex;align-items:center;gap:10px">
<span class="status-dot ${esc(b.state)}" style="display:inline-block;flex-shrink:0"></span>
<div style="flex:1;min-width:0">
<div class="playlist-item-name" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(s.device_name || d.name || 'Display')}</div>
<div class="playlist-item-meta" style="font-size:11px">
<span class="wall-panel-liveness"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>${meta ? ` · ${meta}` : ''}
</div>
</div>
${(!Array.isArray(d.capabilities) || d.capabilities.includes('remote.screenshot')) ? `
<button class="btn btn-sm wall-panel-shot" data-device-id="${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
title="Ask this panel for a screenshot — safe on a live wall, it doesn't change what's playing">Screenshot</button>` : ''}
<a class="btn btn-sm" href="#/device/${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
title="Device info, incident log and remote controls">Open</a>
</div>`;
}).join('')}
</div>
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
Opening a panel doesn't remove it from the wall. Pushing different content to one panel
will desync the wall use the wall playlist above instead.
</p>`;
// The button is only rendered for a panel that declares (or baselines to) remote.screenshot —
// a BrightSign has no screenshot capability at all, so the old unconditional button popped a
// toast promising an image that was never coming.
host.querySelectorAll('.wall-panel-shot').forEach(btn => {
btn.addEventListener('click', () => {
requestScreenshot(btn.dataset.deviceId);
showToast('Screenshot requested — it appears on the panel\'s device page', 'info');
});
});
}
const panelStatusHandler = (data) => {
if (!data?.device_id) return;
liveStatus[data.device_id] = data;
// Keep the canvas tile dots in step with the list, or the two halves of this screen disagree
// about whether a panel is up.
const scr = screens.find(s => s.device_id === data.device_id);
if (scr && data.status) scr.device_status = data.status;
const dot = canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(data.device_id)}"] .status-dot`);
if (dot && data.status) dot.className = `status-dot ${data.status}`;
renderPanelStatus();
};
on('device-status', panelStatusHandler);
cleanupHooks.push(() => off('device-status', panelStatusHandler));
function renderSidebar() {
const sidebar = document.getElementById('availableDevices');
const unassigned = getUnassigned();
@ -579,7 +745,11 @@ async function renderWallEditor(container, wallId) {
if (data.type !== 'sidebar-device' || !data.device_id) return;
const vpRect = viewport.getBoundingClientRect();
// #14: size the new tile to the device's render resolution (centered on the drop point).
const sz = renderSizeFor(data.device_id);
// #236: ...as its FOOTPRINT, so a panel already set to portrait drops in as a tall tile that
// matches how it is actually hung, instead of a landscape tile the operator has to reason
// backwards from.
const rotation = defaultRotationFor(data.device_id);
const sz = footprintOnWall(data.device_id, rotation);
// Drop pixel → canvas-data coord: undo viewport offset, pan, and zoom.
const x = (e.clientX - vpRect.left - pan.x) / zoom - sz.w / 2;
const y = (e.clientY - vpRect.top - pan.y) / zoom - sz.h / 2;
@ -587,7 +757,7 @@ async function renderWallEditor(container, wallId) {
device_id: data.device_id,
device_name: data.device_name || 'Display',
device_status: data.device_status || 'offline',
grid_col: 0, grid_row: 0, rotation: 0,
grid_col: 0, grid_row: 0, rotation,
x, y, w: sz.w, h: sz.h,
});
markDirty();

View file

@ -213,7 +213,7 @@ function openContentPicker({ multiple = false, title } = {}) {
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column">
<h3 style="margin:0 0 12px;color:var(--text-primary)">${title || t('widget.picker.default_title')}</h3>
<h3 style="margin:0 0 12px;color:var(--text-primary)">${esc(title || t('widget.picker.default_title'))}</h3>
<input type="text" id="cpSearch" class="input" placeholder="${t('widget.picker.search')}" style="margin-bottom:12px">
<div id="cpList" style="flex:1;overflow-y:auto;min-height:200px"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px;gap:8px;flex-wrap:wrap">
@ -307,6 +307,15 @@ function showPreviewModal(sessionId, widgetType) {
<strong style="color:var(--text-primary)">${t('widget.preview_title')}</strong>
<button class="btn btn-secondary btn-sm" id="pvClose">${t('widget.close')}</button>
</div>
<!-- ALWAYS 'allow-scripts', never allow-same-origin, regardless of the org's
widget_sandbox_isolation_disabled setting. This preview loads
/api/widgets/preview-session/<id> from the DASHBOARD's own origin, and the
dashboard keeps its session JWT in localStorage.token. Granting same-origin
here would let anyone who can author a widget (workspace_editor and up) run
script in the dashboard origin and read the session of whichever admin opens
the preview an editor -> admin escalation. The org setting exists to let
PLAYERS embed origin-strict sites; it is not a licence to de-isolate the
dashboard. Covered by widget-preview-stays-isolated.test.js. -->
<iframe id="pvIframe" sandbox="allow-scripts" style="flex:1;width:100%;border:0;background:#000"></iframe>
${webpageNote}
</div>`;
@ -400,7 +409,7 @@ export async function render(container) {
break;
case 'weather':
html += `
<div class="form-group"><label>${t('widget.field.location')}</label><input type="text" id="wLocation" class="input" value="${config.location || ''}" placeholder="${t('widget.field.location_placeholder')}"></div>
<div class="form-group"><label>${t('widget.field.location')}</label><input type="text" id="wLocation" class="input" value="${esc(config.location || '')}" placeholder="${t('widget.field.location_placeholder')}"></div>
<div class="form-group"><label>${t('widget.field.units')}</label><select id="wUnits" class="input" style="background:var(--bg-input)"><option value="imperial" ${config.units !== 'metric' ? 'selected' : ''}>${t('widget.field.units_imperial')}</option><option value="metric" ${config.units === 'metric' ? 'selected' : ''}>${t('widget.field.units_metric')}</option></select></div>
<div class="form-group"><label>${t('widget.field.font_size')}</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 48}"></div>
<div class="form-group"><label>${t('widget.field.color')}</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>`;
@ -429,7 +438,7 @@ export async function render(container) {
case 'social':
html += `
<div class="form-group"><label>${t('widget.field.platform')}</label><select id="wPlatform" class="input" style="background:var(--bg-input)"><option value="twitter">${t('widget.field.platform_twitter')}</option><option value="instagram">${t('widget.field.platform_instagram')}</option></select></div>
<div class="form-group"><label>${t('widget.field.query')}</label><input type="text" id="wQuery" class="input" value="${config.query || ''}" placeholder="${t('widget.field.query_placeholder')}"></div>`;
<div class="form-group"><label>${t('widget.field.query')}</label><input type="text" id="wQuery" class="input" value="${esc(config.query || '')}" placeholder="${t('widget.field.query_placeholder')}"></div>`;
break;
case 'directory-board':
html += `

View file

@ -289,7 +289,7 @@ export function mapMutationError(err) {
}
function renderError(message) {
return `<div style="color:var(--danger);font-size:14px;padding:16px;background:var(--bg-input);border-radius:6px">${message}</div>`;
return `<div style="color:var(--danger);font-size:14px;padding:16px;background:var(--bg-input);border-radius:6px">${esc(message)}</div>`;
}
function formatDate(ts) {

View file

@ -210,6 +210,14 @@
</a>
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
</div>
<!-- Live deployment count. Hidden until the number arrives, so a self-hosted instance (where
the endpoint does not exist) and a brand-new one (where the count is 0) show nothing at
all rather than an empty frame or a zero. -->
<p id="deployed-stat" hidden style="margin-top:28px;color:var(--muted);font-size:15px">
<strong id="deployed-count" style="color:var(--text,inherit);font-variant-numeric:tabular-nums"></strong>
screens deployed with ScreenTinker
</p>
</section>
<!-- Intro video -->
@ -630,6 +638,19 @@
// replace it on the public marketing page with a hardcoded Contact Us
// card. Other consumers of /api/subscription/plans (billing.js,
// settings.js, admin.js) get the full list as before.
/* Screens deployed. Only the deployment that collects install statistics answers this;
everywhere else it 404s and the line stays hidden. Failures are silent by design —
a marketing page must not show a broken stat, and there is nothing a visitor could
do about it. */
fetch('/api/public/stats')
.then(r => (r.ok ? r.json() : null))
.then(s => {
if (!s || !(s.screens > 0)) return;
document.getElementById('deployed-count').textContent = s.screens.toLocaleString();
document.getElementById('deployed-stat').hidden = false;
})
.catch(() => {});
fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
const grid = document.getElementById('pricingGrid');
const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');

View file

@ -4,9 +4,16 @@ Third-party libraries committed directly to the repo (not fetched from a CDN or
from npm) so self-hosted / air-gapped instances work with no external dependency and no
build step.
**Anything added here ships in the release tarball**, so it must carry its licence notice —
a minified bundle usually has its headers stripped, which is exactly when the notice has to
be kept as a separate file next to it. Record the licence below and add a `<name>.LICENSE`.
## redoc.standalone.js
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
- **Version:** 2.3.9
- **Licence:** MIT — Copyright (c) 2015-present, Rebilly, Inc. Full text in
[`redoc.LICENSE`](redoc.LICENSE). The bundle itself carries no header (stripped by the
upstream minifier), which is why the notice is kept separately.
- **Source:** https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js
- **Why committed:** the API reference must render on offline instances — no CDN, no build step.
- **Regenerate / update:**

31
frontend/vendor/redoc.LICENSE vendored Normal file
View file

@ -0,0 +1,31 @@
Redoc — https://github.com/Redocly/redoc
Version vendored here: 2.3.9 (see redoc.standalone.js)
The bundle in this directory is minified and its license headers were stripped upstream, so
the notice is kept alongside it instead. MIT requires this notice to accompany the software
wherever it is distributed, and redoc.standalone.js is included in the ScreenTinker release
tarball.
--------------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2015-present, Rebilly, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
'use strict';
/*
* Licence gate for the APK.
*
* node scripts/android-license-check.js [--sbom <path>]
*
* Resolves the real `releaseRuntimeClasspath` every artifact that can end up inside the APK a
* customer installs, transitive ones included and checks each against android/licenses.json.
*
* Fails on an artifact nobody has recorded a licence for. That is the case worth catching:
* org.json:json:20090211 reached customers because it arrived as a transitive dependency of
* socket.io-client and nothing ever asked what licence it carried.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const ANDROID = path.join(ROOT, 'android');
const POLICY = JSON.parse(fs.readFileSync(path.join(ANDROID, 'licenses.json'), 'utf8'));
const SBOM_OUT = process.argv.includes('--sbom') ? process.argv[process.argv.indexOf('--sbom') + 1] : null;
function resolveClasspath() {
const out = execFileSync('./gradlew', ['-q', 'app:dependencies', '--configuration', 'releaseRuntimeClasspath'],
{ cwd: ANDROID, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' });
const found = new Map();
for (const raw of out.split('\n')) {
// Gradle prints "group:name:requested -> resolved" when a version is upgraded; the resolved
// one is what ships, so prefer the right-hand side.
const m = raw.match(/([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([0-9][a-zA-Z0-9._-]*)(?:\s*->\s*([0-9][a-zA-Z0-9._-]*))?/);
if (!m) continue;
const [, group, name, requested, upgraded] = m;
found.set(`${group}:${name}`, { group, name, version: upgraded || requested });
}
return [...found.values()].sort((a, b) => `${a.group}:${a.name}`.localeCompare(`${b.group}:${b.name}`));
}
function licenceFor(a) {
const coord = `${a.group}:${a.name}`;
if (POLICY.denied[coord]) return { verdict: 'DENY', why: POLICY.denied[coord].why };
if (POLICY.artifacts[coord]) return { verdict: 'ALLOW', ...POLICY.artifacts[coord] };
// Longest matching group prefix wins, so a specific rule beats a broad one.
const groups = Object.keys(POLICY.groups)
.filter(g => a.group === g || a.group.startsWith(g + '.'))
.sort((x, y) => y.length - x.length);
if (groups.length) return { verdict: 'ALLOW', ...POLICY.groups[groups[0]] };
return { verdict: 'UNKNOWN' };
}
const artifacts = resolveClasspath();
if (!artifacts.length) {
console.error('Resolved no artifacts — the gradle task did not run properly. Refusing to pass.');
process.exit(2);
}
const results = artifacts.map(a => ({ ...a, ...licenceFor(a) }));
const denied = results.filter(r => r.verdict === 'DENY');
const unknown = results.filter(r => r.verdict === 'UNKNOWN');
for (const r of results.filter(r => r.verdict === 'ALLOW')) {
for (const d of POLICY.denied_licenses) {
if (new RegExp(d.match, 'i').test(r.license)) { denied.push({ ...r, why: `${r.license}: ${d.why}` }); }
}
}
const counts = results.reduce((m, r) => (m[r.license || '(unrecorded)'] = (m[r.license || '(unrecorded)'] || 0) + 1, m), {});
console.log(`\nScope: ${results.length} artifacts on releaseRuntimeClasspath (everything that can enter the APK)\n`);
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
if (SBOM_OUT) {
const sbom = {
bomFormat: 'CycloneDX',
specVersion: '1.5',
version: 1,
metadata: {
component: {
type: 'application',
name: 'screentinker-android-player',
version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(),
licenses: [{ license: { id: 'MIT' } }],
},
},
components: results.map(r => ({
type: 'library',
name: `${r.group}:${r.name}`,
version: r.version,
purl: `pkg:maven/${r.group}/${r.name}@${r.version}`,
licenses: r.license ? [{ license: { id: r.license } }] : [],
})),
};
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
}
let failed = false;
if (denied.length) {
failed = true;
console.log('\nDENIED');
denied.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}\n ${r.why}`));
}
if (unknown.length) {
failed = true;
console.log('\nUNRECORDED — a new dependency reached the APK with no licence on file.');
console.log('Look it up, then add it to android/licenses.json with evidence, or exclude it.');
unknown.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}`));
}
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: every artifact in the APK has a recorded, permitted licence.\n');
process.exit(failed ? 1 : 0);

184
scripts/license-check.js Normal file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env node
'use strict';
/*
* Licence gate for the dependencies that actually SHIP.
*
* node scripts/license-check.js [--sbom <path>] [--include-dev]
*
* Run from a PRODUCTION install (`npm ci --omit=dev`). That is the whole point: a developer
* checkout carries `sharp`, whose `@img/sharp-wasm32` declares LGPL-3.0-or-later. It is a test
* fixture generator, it is devDependencies-only, and it never reaches a server but a scanner
* pointed at a dev tree reports LGPL and contradicts the answer we give customers. Auditing the
* installed production tree is what makes the answer defensible.
*
* Exits non-zero on anything denied or unresolved, so CI fails before a licence can arrive
* unnoticed through a transitive bump.
*
* No dependencies, deliberately a gate that needs its own supply chain audited is worth less.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const args = process.argv.slice(2);
const INCLUDE_DEV = args.includes('--include-dev');
const SBOM_OUT = args.includes('--sbom') ? args[args.indexOf('--sbom') + 1] : null;
const SERVER_DIR = path.join(__dirname, '..', 'server');
/* policy
* ALLOW: permissive, no distribution obligation beyond keeping the notice.
* DENY: strong/network copyleft, plus licences we will not ship for other reasons.
* Anything matching neither is REVIEW it fails, and a human decides. Failing closed
* matters more than being clever: the risk is a licence arriving that nobody looked at.
*/
const ALLOW = [
/^MIT$/i, /^MIT-0$/i, /^ISC$/i, /^0BSD$/i, /^BSD-2-Clause$/i, /^BSD-3-Clause$/i,
/^Apache-2\.0$/i, /^BlueOak-1\.0\.0$/i, /^Unlicense$/i, /^CC0-1\.0$/i, /^Python-2\.0$/i,
/^WTFPL$/i, /^Zlib$/i, /^CC-BY-4\.0$/i,
];
const DENY = [
{ re: /\bAGPL/i, why: 'network copyleft — obligations trigger on serving, not distributing' },
{ re: /\bGPL-[123]|\bGPLv[123]|(^|[^L])\bGPL\b/i, why: 'strong copyleft — links into a product we distribute commercially' },
{ re: /\bSSPL/i, why: 'server-side public licence — not OSI-approved, service-scope obligations' },
{ re: /\bCommons-Clause/i, why: 'commercial-use restriction' },
{ re: /\bBUSL|Business Source/i, why: 'source-available, not open source' },
{ re: /Good, not Evil|^JSON$/i, why: 'JSON Licence — field-of-use clause, Apache Category X, non-free per Debian/Fedora' },
];
// Weak copyleft: file- or library-scoped, generally fine when merely linked, but never silently.
const REVIEW = [/\bLGPL/i, /\bMPL/i, /\bEPL/i, /\bCDDL/i, /\bOSL/i, /\bEUPL/i, /\bCPL/i];
/*
* Packages that ship a real licence FILE but declare no `license` field in package.json.
* Each entry records what was read off disk, so this is a documented finding rather than a
* blanket exemption. Re-verify if the version changes.
*/
const EXCEPTIONS = {
'exif-parser': { license: 'MIT', evidence: 'LICENSE.md — "The MIT License"' },
'thirty-two': { license: 'MIT', evidence: 'LICENSE.txt — MIT, Copyright (c) 2011 Chris Umbel' },
'screentinker': { license: 'MIT', evidence: 'repository root LICENSE' },
};
function classify(id) {
if (!id) return { verdict: 'UNKNOWN' };
for (const d of DENY) if (d.re.test(id)) return { verdict: 'DENY', why: d.why };
// A GPL-with-exception (Classpath, linking) is not the thing we are guarding against.
if (/WITH .*exception/i.test(id)) return { verdict: 'REVIEW', why: 'copyleft with a linking exception' };
for (const r of REVIEW) if (r.test(id)) return { verdict: 'REVIEW', why: 'weak copyleft' };
// Composite expressions: every term must be allowed.
const terms = id.split(/\s+(?:OR|AND)\s+|[()]/).map(s => s.trim()).filter(Boolean);
if (terms.length && terms.every(t => ALLOW.some(a => a.test(t)))) return { verdict: 'ALLOW' };
return { verdict: 'UNKNOWN' };
}
function readLicense(dir) {
let pkg;
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
let lic = pkg.license;
if (lic && typeof lic === 'object') lic = lic.type;
if (!lic && Array.isArray(pkg.licenses)) lic = pkg.licenses.map(l => l.type || l).join(' OR ');
return { name: pkg.name, version: pkg.version, license: lic || null };
}
/*
* `npm ls` exits non-zero for any tree problem an extraneous package, a peer-dep complaint
* while still printing the full listing. Treating that as fatal would turn a routine tree quirk
* into an unexplained CI failure, and worse, a licence check that never actually ran. Read the
* output either way; a genuinely empty result is the only thing worth aborting on.
*/
function listInstalled() {
const argv = ['ls', ...(INCLUDE_DEV ? [] : ['--omit=dev']), '--all', '--parseable'];
const opts = { cwd: SERVER_DIR, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' };
try {
return execFileSync('npm', argv, opts);
} catch (e) {
if (e.stdout && e.stdout.trim()) return e.stdout;
console.error('npm ls produced no output:\n' + (e.stderr || e.message));
process.exit(2);
}
}
const dirs = listInstalled().split('\n').filter(Boolean);
const pkgs = [];
const seen = new Set();
for (const d of dirs) {
const info = readLicense(d);
if (!info || !info.name) continue;
const key = `${info.name}@${info.version}`;
if (seen.has(key)) continue;
seen.add(key);
let license = info.license;
let note = null;
if (!license && EXCEPTIONS[info.name]) {
license = EXCEPTIONS[info.name].license;
note = `no license field; ${EXCEPTIONS[info.name].evidence}`;
}
pkgs.push({ ...info, license, note, ...classify(license) });
}
const denied = pkgs.filter(p => p.verdict === 'DENY');
const review = pkgs.filter(p => p.verdict === 'REVIEW');
const unknown = pkgs.filter(p => p.verdict === 'UNKNOWN');
const counts = pkgs.reduce((m, p) => (m[p.license || '(none)'] = (m[p.license || '(none)'] || 0) + 1, m), {});
console.log(`\nScope: ${pkgs.length} packages (${INCLUDE_DEV ? 'INCLUDING dev' : 'production only, --omit=dev'})\n`);
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
if (SBOM_OUT) {
// CycloneDX 1.5, hand-built. A standard format customers and underwriters recognise, without
// taking a dependency on a generator to produce it.
const sbom = {
bomFormat: 'CycloneDX',
specVersion: '1.5',
version: 1,
metadata: {
component: {
type: 'application',
name: 'screentinker',
version: fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(),
licenses: [{ license: { id: 'MIT' } }],
},
properties: [{ name: 'screentinker:scope', value: INCLUDE_DEV ? 'all' : 'production' }],
},
components: pkgs
.filter(p => p.name !== 'screentinker')
.sort((a, b) => a.name.localeCompare(b.name))
.map(p => ({
type: 'library',
name: p.name,
version: p.version,
purl: `pkg:npm/${p.name.replace('@', '%40')}@${p.version}`,
licenses: p.license ? [{ license: /[()]| OR | AND /.test(p.license) ? { name: p.license } : { id: p.license } }] : [],
})),
};
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
}
let failed = false;
if (denied.length) {
failed = true;
console.log('\nDENIED');
denied.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license}\n ${p.why}`));
}
if (unknown.length) {
failed = true;
console.log('\nUNRESOLVED — no recognised licence. Read the package, then add it to EXCEPTIONS');
console.log('with the evidence, or remove the dependency.');
unknown.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license || '(no license field)'}`));
}
if (review.length) {
// Not fatal, but never silent — weak copyleft is a judgement call, and the judgement should be
// made by a person who knows it is being made.
console.log('\nREVIEW (not failing)');
review.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license} ${p.why}`));
}
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: no denied or unresolved licences.\n');
process.exit(failed ? 1 : 0);

View file

@ -78,6 +78,7 @@ function runMigration({ db: existingDb = null, dryRun = false, logger = console
default_brand_name TEXT,
default_logo_url TEXT,
default_primary_color TEXT,
widget_sandbox_isolation_disabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

View file

@ -58,17 +58,51 @@ while [[ $# -gt 0 ]]; do
esac
done
# -- Prompting when we are being piped --
#
# The documented install is `curl -sL … | sudo bash`, which makes stdin the SCRIPT, not the
# operator. bash has already consumed it by the time any `read` runs, so every prompt got EOF
# instantly: the mode menu "chose" All-in-One without the operator touching anything, and the
# Player-Only branch could never be reached that way at all. It looked like the menu was being
# skipped, because it was.
#
# So prompts read from the controlling terminal instead. When there genuinely is no terminal
# (cloud-init, a provisioning pipeline), we say so and take the documented default rather than
# pretending a choice was made — the operator can pass --player-only / --server-url to decide
# without a prompt.
if [ -r /dev/tty ] && [ -t 1 ]; then
exec 3</dev/tty
HAVE_TTY=true
else
HAVE_TTY=false
fi
# ask <varname> <prompt> [read-args…]
ask() {
local __var="$1"; shift
local __prompt="$1"; shift
if [ "$HAVE_TTY" = true ]; then
read "$@" -u 3 -r -p "$__prompt" "$__var"
else
eval "$__var=''"
fi
}
# -- Root check --
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root. Try: sudo bash raspberry-pi-setup.sh"
err "This script must be run as root. Try: curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash"
fi
# -- Architecture check --
ARCH=$(uname -m)
if [[ "$ARCH" != "aarch64" && "$ARCH" != "armv7l" ]]; then
warn "Detected architecture: $ARCH (expected aarch64 or armv7l for Raspberry Pi)"
read -p "Continue anyway? (y/N) " -n 1 -r; echo
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
if [ "$HAVE_TTY" = true ]; then
ask REPLY "Continue anyway? (y/N) " -n 1; echo
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
else
err "Refusing to continue on $ARCH without a terminal to confirm at. Re-run interactively, or on the intended hardware."
fi
fi
# -- Interactive mode selection (if no flags passed) --
@ -86,14 +120,24 @@ if [ "$PLAYER_ONLY" = false ] && [ -z "$SERVER_URL" ]; then
echo " Connects to an existing ScreenTinker server."
echo " This Pi just displays content."
echo ""
read -p "Choose [1/2]: " MODE_CHOICE
case "$MODE_CHOICE" in
2)
PLAYER_ONLY=true
read -p "Server URL (e.g., https://screentinker.com): " SERVER_URL
;;
*) ;;
esac
if [ "$HAVE_TTY" = false ]; then
# No terminal to ask at. Say which way we went, rather than letting an empty answer
# look like a decision — this is the exact confusion the piped-stdin bug produced.
warn "No terminal available for the menu — defaulting to All-in-One."
warn "To choose Player-Only non-interactively: ... | sudo bash -s -- --player-only https://your-server"
else
ask MODE_CHOICE "Choose [1/2]: "
case "$MODE_CHOICE" in
2)
PLAYER_ONLY=true
while [ -z "$SERVER_URL" ]; do
ask SERVER_URL "Server URL (e.g., https://screentinker.com): "
[ -z "$SERVER_URL" ] && warn "Player-Only needs a server URL."
done
;;
*) ;;
esac
fi
fi
# Strip trailing slash from server URL
@ -268,22 +312,52 @@ KIOSK_URL="${KIOSK_URL}"
# Wait for display
sleep 2
# Disable screen blanking and power management
xset s off
xset s noblank
xset -dpms
xset s 0 0
# Which display server are we actually on? Pi 5 on Bookworm defaults to WAYLAND, where every
# X11 tool below is a no-op that prints an error into the journal and silently does nothing —
# so a Wayland Pi got no blanking suppression and no cursor hiding while appearing configured.
SESSION_TYPE="\${XDG_SESSION_TYPE:-}"
if [ -z "\$SESSION_TYPE" ]; then
if [ -n "\${WAYLAND_DISPLAY:-}" ]; then SESSION_TYPE=wayland
elif [ -n "\${DISPLAY:-}" ]; then SESSION_TYPE=x11
fi
fi
echo "Display server: \${SESSION_TYPE:-unknown}"
# Hide cursor after 3 seconds of inactivity
unclutter -idle 3 -root &
if [ "\$SESSION_TYPE" = "wayland" ]; then
# Blanking/DPMS belong to the compositor here, not to us. wlopm is present on Pi OS
# (wlroots-based wayfire/labwc); if it is not, the compositor's own idle config is the
# documented fallback and README says so.
command -v wlopm >/dev/null 2>&1 && wlopm --on '*' 2>/dev/null || true
# unclutter is X11-only — it exits immediately here, which is why a Wayland Pi kept its cursor
# on screen while the install looked complete. Hiding it is the COMPOSITOR's job on Wayland;
# the installer configures wayfire's hide-cursor plugin at install time (section 9b). If this
# Pi runs labwc instead, there is no equivalent setting and the cursor stays — README says so
# rather than this pretending otherwise.
else
# Disable screen blanking and power management
xset s off
xset s noblank
xset -dpms
xset s 0 0
# Clean Chromium crash flags (prevents restore session dialogs)
# Hide cursor after 3 seconds of inactivity (X11 only — no Wayland equivalent)
unclutter -idle 3 -root &
fi
# Clean Chromium crash flags (prevents restore session dialogs).
#
# The white page on every boot after the first is Chromium restoring a session it thinks
# crashed: a kiosk is killed by the shutdown, never exits cleanly, and comes back with a
# restore surface on top of the player — which is why ALT+F4 "fixed" it (it closed the
# surface, not the player). Rewriting the flags is not enough on its own because Chromium
# also replays the previous window set from Sessions/, so those go too.
CDIR="\$HOME/.config/chromium/Default"
mkdir -p "\$CDIR"
if [ -f "\$CDIR/Preferences" ]; then
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' "\$CDIR/Preferences" 2>/dev/null || true
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' "\$CDIR/Preferences" 2>/dev/null || true
fi
rm -rf "\$CDIR/Sessions" "\$CDIR/Session Storage" 2>/dev/null || true
# Wait for local server if running all-in-one
if echo "\$KIOSK_URL" | grep -q "localhost"; then
@ -306,8 +380,15 @@ if [ -z "\$SCREEN_W" ] || [ -z "\$SCREEN_H" ]; then
SCREEN_H=1080
fi
# Wayland needs the ozone backend named explicitly on some Bookworm builds; on X11 the flag
# is absent so nothing changes there.
OZONE=""
[ "\$SESSION_TYPE" = "wayland" ] && OZONE="--ozone-platform=wayland"
exec ${CHROMIUM_BIN} \\
--kiosk \\
\$OZONE \\
--password-store=basic \\
--window-position=0,0 \\
--window-size=\${SCREEN_W},\${SCREEN_H} \\
--noerrdialogs \\
@ -454,6 +535,49 @@ ExecStart=-/sbin/agetty --autologin ${PI_USER} --noclear %I \$TERM
EOF
fi
# ============================================================
# 9b. Wayland cursor hiding (wayfire)
# ============================================================
# On X11 the launcher runs `unclutter -idle 3`. On Wayland unclutter cannot work at all — there is
# no root window to track and no client may move or hide another client's cursor — so hiding it is
# the compositor's decision. Pi OS Bookworm on Pi 4/5 defaults to wayfire, which has a hide-cursor
# plugin; this configures it. A previous version of this script claimed in a comment to do exactly
# this and never did, so a Wayland Pi sat there with a mouse pointer on the sign (#245).
#
# Written at install time rather than from the launcher because wayfire reads this at session
# start. It is also idempotent and non-destructive: a Pi whose owner has already tuned wayfire.ini
# keeps their settings, and the file is backed up before the first edit either way.
if [ -f "$PI_HOME/.config/wayfire.ini" ]; then
log "Configuring wayfire to hide the cursor..."
WF="$PI_HOME/.config/wayfire.ini"
[ -f "${WF}.screentinker-bak" ] || cp "$WF" "${WF}.screentinker-bak"
if grep -q '^\[hide-cursor\]' "$WF"; then
log " wayfire.ini already has [hide-cursor] — leaving it alone"
else
printf '\n[hide-cursor]\nhide_delay = 3000\n' >> "$WF"
fi
# The plugin only loads if it is named in core's plugin list, and that list is space-separated
# on one line. Appending to it is fiddly enough to be worth doing carefully rather than with a
# blind sed: only touch the line when it exists and does not already mention us.
if grep -qE '^\s*plugins\s*=' "$WF"; then
if ! grep -E '^\s*plugins\s*=' "$WF" | grep -q 'hide-cursor'; then
sed -i 's/^\(\s*plugins\s*=.*\)$/\1 hide-cursor/' "$WF"
fi
else
warn "wayfire.ini has no [core] plugins line — add 'hide-cursor' to it to hide the pointer"
fi
chown "$PI_USER":"$PI_USER" "$WF" 2>/dev/null || true
elif [ "$HAS_DESKTOP" = true ]; then
# labwc (the newer Pi OS compositor) has no cursor-hiding option, and neither do we from the
# outside. Say so plainly instead of leaving the operator to wonder whether it failed.
if command -v labwc >/dev/null 2>&1; then
warn "This Pi appears to run labwc, which has no cursor-hide setting — the pointer will stay visible."
warn "Switch to wayfire (raspi-config > Advanced > Wayland) or to X11 if a hidden cursor matters."
fi
fi
# ============================================================
# 10. Pi display and boot optimizations
# ============================================================
@ -573,6 +697,52 @@ case "${1:-server}" in
esac
LOGSEOF
chmod +x /usr/local/bin/screentinker-logs
else
# Player-Only gets its own pair. It used to get NONE, while section 12 below wrote an MOTD
# advertising all three to every install — so a player Pi greeted its operator at each SSH
# login with three commands that were never on it (#245). There is no server here to update,
# so screentinker-update is genuinely not applicable and is not offered; status and logs are,
# and a player with no way to answer "is it running?" is the harder machine to support.
log "Creating management scripts (player)..."
cat > /usr/local/bin/screentinker-status << PSTATUSEOF
#!/bin/bash
echo ""
echo "=== ScreenTinker Player Status ==="
echo ""
if systemctl is-active screentinker-kiosk.service &>/dev/null; then
echo "Kiosk: RUNNING"
else
echo "Kiosk: STOPPED (screentinker-logs to see why)"
fi
echo "Server: ${SERVER_URL}"
# Whether this player can actually reach the server it was pointed at — the first question worth
# asking on a panel that is showing nothing.
if curl -sf --max-time 5 "${SERVER_URL}/api/status" >/dev/null 2>&1; then
echo "Reachable: yes"
else
echo "Reachable: NO (network, DNS, or the server is down)"
fi
echo ""
echo "Uptime: \$(uptime -p)"
echo "CPU Temp: \$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2 || echo 'n/a')"
echo "Disk: \$(df -h / 2>/dev/null | tail -1 | awk '{print \$3 "/" \$2 " (" \$5 " used)"}')"
echo "Memory: \$(free -h | awk '/Mem:/ {print \$3 " / " \$2}')"
echo ""
PSTATUSEOF
chmod +x /usr/local/bin/screentinker-status
cat > /usr/local/bin/screentinker-logs << 'PLOGSEOF'
#!/bin/bash
# Only the kiosk exists on a player, so it is the default AND the only target. Accepting
# "server" here and following an empty unit would be a worse answer than saying so.
case "${1:-kiosk}" in
kiosk|all) journalctl -u screentinker-kiosk.service -f --no-hostname ;;
server) echo "This is a player-only install — there is no local server. Point at your server's logs instead." ;;
*) echo "Usage: screentinker-logs [kiosk]" ;;
esac
PLOGSEOF
chmod +x /usr/local/bin/screentinker-logs
fi
# ============================================================
@ -580,20 +750,37 @@ fi
# ============================================================
cat > /etc/motd << 'MOTDEOF'
____ _____ _
/ ___| ___ _ __ ___ ___ |_ _|_ _ __ | | _____ _ __
\___ \ / __| '__/ _ \/ _ \ | || | '_ \| |/ / _ \ '__|
___) | (__| | | __/ __/ | || | | | | < __/ |
|____/ \___|_| \___|\___| |_||_|_| |_|_|\_\___|_|
____ _____ _ _
/ ___| ___ _ __ ___ ___ _ __ |_ _|(_) _ __ | | __ ___ _ __
\___ \ / __|| '__| / _ \ / _ \| '_ \ | | | || '_ \ | |/ / / _ \| '__|
___) || (__ | | | __/| __/| | | | | | | || | | || < | __/| |
|____/ \___||_| \___| \___||_| |_| |_| |_||_| |_||_|\_\ \___||_|
Open-Source Digital Signage for Any Screen
MOTDEOF
# The command list is appended SEPARATELY and per-mode, because section 11 creates
# screentinker-update on an All-in-One install only. A single hard-coded list here is what made a
# Player-Only Pi advertise three commands it did not have, at every SSH login (#245). The MOTD is
# the first thing an operator reads on a machine that is misbehaving, which makes it the worst
# place in the system to be confidently wrong.
if [ "$PLAYER_ONLY" = false ]; then
cat >> /etc/motd << 'MOTDCMDEOF'
Commands:
screentinker-status Show system info and URLs
screentinker-update Pull latest and restart
screentinker-logs Follow logs (server|kiosk|all)
MOTDEOF
MOTDCMDEOF
else
cat >> /etc/motd << 'MOTDCMDEOF'
Commands:
screentinker-status Kiosk state, server URL, and whether it is reachable
screentinker-logs Follow the kiosk log
MOTDCMDEOF
fi
# ============================================================
# 13. Clean up legacy remotedisplay naming

View file

@ -84,11 +84,14 @@ module.exports = {
return secret;
})(),
jwtExpiry: '7d',
// Google OAuth - set these in env or here
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
// Microsoft OAuth - set these in env or here
microsoftClientId: process.env.MICROSOFT_CLIENT_ID || '',
microsoftTenantId: process.env.MICROSOFT_TENANT_ID || 'common',
/*
* Google and Microsoft sign-in are configured through lib/oidc-providers.js, which reads
* process.env directly there is nothing here for it to read, so these fields were dead, and
* `microsoftTenantId` defaulting to 'common' actively contradicted the provider code, which now
* REFUSES 'common' (it advertises a template issuer that can never match, and accepting it means
* accepting tokens from every Azure tenant nOAuth). Removed rather than left as a trap for the
* next person who greps for where Microsoft SSO is configured.
*/
// Stripe (optional - for paid subscriptions)
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
@ -226,6 +229,11 @@ module.exports = {
// is LOWER than the old hardcoded 7 days (the reporter's bloat happened under 7d);
// 2-3 days is plenty for the dashboard's 24h uptime view + diagnostics.
statusLogRetentionDays: parseFloat(process.env.STATUS_LOG_RETENTION_DAYS) || 3,
// #240 device_telemetry age retention (pruneTelemetryRetention in db/database.js). The
// per-heartbeat row cap only trims devices that are still reporting; this closes the
// rows left behind by ones that stopped. 30d matches the uptime report's default window,
// so it can only remove rows the report would not have shown anyway.
telemetryRetentionDays: parseFloat(process.env.TELEMETRY_RETENTION_DAYS) || 30,
// #146 HARD per-device row-count ceiling on device_status_log, enforced by the
// global sweep alongside the age delete above. Age-based retention can't bound a
// write storm (rows are all younger than the window), so a reconnect storm grew
@ -303,6 +311,24 @@ module.exports = {
// ...or escalate if the WAL grew across this many consecutive PASSIVE runs (PASSIVE not
// keeping up even below the high-water). Belt-and-suspenders with the MB bound above.
walCheckpointStarvationRuns: parseInt(process.env.WAL_CHECKPOINT_STARVATION_RUNS) || 3,
// #240: ...but growth alone is NOT starvation. Any sustained write burst — a fleet
// powering on in the morning — grows the WAL across several consecutive PASSIVE runs
// while it is still tiny. Escalating there buys nothing (there is nothing to reclaim)
// and costs a lot: TRUNCATE is the BLOCKING form, and it blocks across connections, so
// every main-thread statement issued during it sits in SQLite's busy handler (5s by
// default in better-sqlite3) — a multi-second loop stall at exactly the moment the
// fleet is reconnecting. So the growth signal may only escalate once the WAL is big
// enough for a blocking checkpoint to be worth it. The high-water mark above is
// unchanged and remains the hard backstop, so the WAL still cannot grow unbounded.
// Set at half the high-water mark: a WAL still in the lower half is not worth blocking
// for, and anything in the upper half is close enough to the backstop to be worth it.
walCheckpointStarvationFloorMB: parseFloat(process.env.WAL_CHECKPOINT_STARVATION_FLOOR_MB) || 8,
// #240: the floor alone is not enough — a WAL that already sits above it (Bold's was
// 6.2MB against a 16MB high-water) would still escalate on every burst. So the growth
// path is ALSO rate-limited: however long the write pressure lasts, our own maintenance
// may stall the loop at most once per this window. The high-water escalation is
// deliberately EXEMPT — that one is the runaway-WAL backstop and must never be delayed.
walCheckpointEscalateCooldownMs: parseInt(process.env.WAL_CHECKPOINT_ESCALATE_COOLDOWN_MS) || 300000,
// Worker-death handling: with autocheckpoint=0 a dead worker means nothing checkpoints and
// the WAL grows until the disk fills. An unexpectedly-dead worker is respawned up to
// RespawnMax times per RespawnWindowMs (with a small backoff); if that's exhausted we

View file

@ -382,6 +382,121 @@ const migrations = [
// the PUBLIC address the server sees the connection arrive from — both are useful and they are
// not the same thing. A customer reading the public IP as "my screen's IP" prompted this.
"ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT",
// ...and its IPv6 one, in its own column rather than sharing the above. The player's collector
// filtered to Inet4Address, so a v6-only panel reported no address at all and the dashboard
// showed a dash for a screen that had a perfectly reachable address. Separate columns because a
// dual-stack panel genuinely has both and an operator may need either — collapsing them would
// make the field mean "whichever we happened to enumerate first".
"ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT",
// What is physically PLUGGED IN, read from the display's EDID, and the mode actually being
// driven. A signage operator's first question about a dark screen is which panel it is and
// whether the player is outputting at all — the dashboard could say neither, and
// screen_width/height are what the PAGE thinks it has, not what the hardware negotiated.
//
// Per-telemetry-row rather than on `devices` because a display can be swapped, unplugged or
// renegotiated without the player re-registering, and because a dual-output player registers ONE
// ROW PER OUTPUT (see output_index) — each row must carry its own screen, not the box's first.
/*
* Per-organization SSO.
*
* Instance-wide providers come from the environment and belong to whoever runs the server. These
* belong to a CUSTOMER: an organization brings its own identity provider, and its people sign in
* with it without the operator touching a config file.
*
* `slug` is globally unique and randomly generated rather than chosen, because it is a URL path
* segment (/api/auth/oidc/<slug>/start) and two organizations both wanting "okta" must not be
* able to collide or to guess each other's. The admin only ever sees `name`.
*
* `client_secret_enc` is AES-256-GCM via lib/secretbox, the same at-rest treatment as TOTP
* secrets and BYOK AI keys. PKCE means a secret is optional, so a public client stores NULL.
*
* `email_domains` is the list an admin TYPED, kept for display and for the edit form. It does not
* drive routing org_sso_domains does, and only its verified rows (see the table below). The two
* are not interchangeable: reading this column to decide who may sign in would let a tenant route
* a domain it never proved.
*/
`CREATE TABLE IF NOT EXISTS org_sso_providers (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
issuer TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret_enc TEXT,
scopes TEXT NOT NULL DEFAULT 'openid email profile',
email_domains TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_org_sso_org ON org_sso_providers(organization_id)",
/*
* Claimed sign-in domains, and the proof that the claimant controls them.
*
* `org_sso_providers.email_domains` used to be the whole story, and first-claim-wins on a text
* field is not a claim it is a land grab. A tenant could type a domain it had nothing to do
* with and every person at that company typing their work address into the login page would be
* routed to the squatter's identity provider. It also let one account permanently deny a domain
* to its real owner, and strand accounts at addresses it never owned.
*
* So a domain is inert until DNS says otherwise. `verified_at` NULL means claimed but unproven:
* it routes nobody, and the login callback will not accept an assertion for it. The row still
* reserves the name, so two tenants cannot race the same domain, but reserving is all it does.
*
* `token` is what has to appear in DNS. It is per-domain rather than per-organization so that
* publishing one proof cannot be replayed to claim a second domain.
*/
`CREATE TABLE IF NOT EXISTS org_sso_domains (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
provider_id TEXT,
domain TEXT NOT NULL UNIQUE,
token TEXT NOT NULL,
-- When the current token was issued. An UNVERIFIED claim is only good for 8 hours from here:
-- past that the token is dead and the reservation lapses, so a domain nobody can prove cannot
-- be held indefinitely by whoever typed it first. Verified rows ignore this entirely.
token_issued_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
verified_at INTEGER,
last_checked_at INTEGER,
last_error TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
-- A verified row never expires and domain is globally UNIQUE, so a row that outlives its
-- provider blocks that domain for EVERYONE, forever, while being invisible in the API. The
-- delete handler clears these explicitly; this is the backstop for every other route out
-- (an organization cascade, a manual delete, a future caller that forgets).
FOREIGN KEY (provider_id) REFERENCES org_sso_providers(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_org ON org_sso_domains(organization_id)",
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_provider ON org_sso_domains(provider_id)",
/*
* SSO-ONLY: an organization may require its people to use its identity provider, so a password
* is no longer an alternative way in. That is the point of buying SSO the IdP holds the MFA,
* the conditional access and the instant deprovisioning, and a password box beside it is a way
* around all three.
*
* Asymmetric on purpose. Turning it ON is the safe direction and an org admin does it alone.
* Turning it OFF is how a compromised admin would re-open password login, and it is also what
* an org will demand at its worst moment IdP down, nobody can work which is exactly when a
* self-service switch gets flipped under pressure. So removal goes through the operator: the
* request is recorded here and a platform admin has to approve it.
*/
`CREATE TABLE IF NOT EXISTS org_sso_only_requests (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
requested_by TEXT,
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | cancelled
decided_by TEXT,
decided_at INTEGER,
decision_note TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_sso_only_req_status ON org_sso_only_requests(status, organization_id)",
"ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT",
"ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT",
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because
// only some hardware exposes one — Android and the browser players send nothing and must keep
// reading as "no sensor" rather than "0 degrees", which is why every read site treats null as
@ -472,6 +587,7 @@ const migrations = [
// additive — existing rows are unaffected and a code-only rollback leaves dead columns.
"ALTER TABLE users ADD COLUMN password_reset_hash TEXT",
"ALTER TABLE users ADD COLUMN password_reset_expires INTEGER",
"ALTER TABLE organizations ADD COLUMN widget_sandbox_isolation_disabled INTEGER NOT NULL DEFAULT 0",
// AUTH-05: make break-glass recovery revocable, single-use and auditable.
//
// scripts/reset-admin.js mints a JWT carrying `recovery: true`, which middleware/auth.js
@ -525,6 +641,19 @@ const migrations = [
// it can do nothing and must be respected.
'ALTER TABLE devices ADD COLUMN capabilities TEXT',
// Opt-in install statistics, COLLECTOR side only — inert unless TELEMETRY_COLLECTOR=1, which
// is the hosted deployment. Keyed by instance_id and upserted rather than appended, so it is a
// table of current state ("this install last reported N screens") rather than an event log that
// grows without bound on a box nobody prunes. Answering "how many screens are deployed" needs
// the latest row per install, never the history.
`CREATE TABLE IF NOT EXISTS telemetry_reports (
instance_id TEXT PRIMARY KEY,
version TEXT,
screen_count INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
)`,
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.
@ -549,6 +678,40 @@ for (const sql of migrations) {
}
if (_migApplied > 0) console.log(`[migrate] applied ${_migApplied} new column migration(s)`);
/*
* Say something when per-org SSO domains predate the proof requirement.
*
* Domains used to be a comma list an admin typed, and that list routed logins. They now route only
* once DNS proves them, so on an instance upgraded from an earlier build of this feature every one
* of those domains silently stops working the provider still says "enabled", the typed list is
* still on screen, and every federated user in that organization is locked out with no self-service
* way back.
*
* They are deliberately NOT auto-claimed. A claim now notifies the operator, reserves the name
* against other tenants and starts an 8-hour clock; manufacturing all of that on an admin's behalf,
* for domains nobody ever proved, is not a migration's decision to make. So: name them, loudly,
* once per boot, and let an admin re-add the ones they still want.
*/
try {
const stranded = db.prepare(`
SELECT p.slug, p.name, p.organization_id, p.email_domains
FROM org_sso_providers p
WHERE p.email_domains != ''
AND NOT EXISTS (SELECT 1 FROM org_sso_domains d WHERE d.provider_id = p.id)
`).all();
if (stranded.length) {
console.warn(`[migrate] ⚠️ ${stranded.length} SSO provider(s) have typed domains that were never verified.`);
console.warn('[migrate] Domains now route only after a DNS TXT record proves them, so these route NOBODY:');
for (const r of stranded) {
console.warn(`[migrate] ${r.name} (${r.slug}, org ${r.organization_id}): ${r.email_domains}`);
}
console.warn('[migrate] Re-add each domain in Settings to get its record, then Verify. See README, "Proving a domain".');
}
} catch (e) {
// The table may not exist yet on a first boot; that is not a problem worth a stack trace.
if (!/no such table/i.test(e.message)) console.error('[migrate] SSO domain check failed:', e.message);
}
// #74/#75 per-item schedules: the playlist_item_schedules table is created
// idempotently by schema.sql (CREATE TABLE IF NOT EXISTS, run every boot, so it
// self-applies on upgrade). Record it in schema_migrations for observability.
@ -809,6 +972,27 @@ migrateGroupSchedules();
// updates workspace_id.
ensureMultitenancyMigration();
/*
* `organizations.sso_only` added HERE, not in the migrations array above.
*
* That array runs BEFORE ensureMultitenancyMigration(), which is what creates the organizations
* table, so on a fresh install the ALTER hit a table that did not exist yet: `[migrate] FAILED …
* no such table: organizations`, one console.error among ~85 migration lines. The instance then
* ran its entire first boot with the SSO settings screen 500ing and far worse
* ssoOnlyForEmail() catching `no such column` and returning "not SSO-only", which is password
* login proceeding for an organization that had switched it off. It self-healed on the second
* boot, which is exactly what makes it easy to miss.
*/
try {
const orgCols = db.prepare('PRAGMA table_info(organizations)').all().map((c) => c.name);
if (orgCols.length && !orgCols.includes('sso_only')) {
db.exec('ALTER TABLE organizations ADD COLUMN sso_only INTEGER NOT NULL DEFAULT 0');
console.log('[migrate] added organizations.sso_only');
}
} catch (e) {
console.error('[migrate] could not add organizations.sso_only:', e.message);
}
// Phase 2.2c migration: backfill content_folders.workspace_id from owner's
// default workspace. The ALTER lives in the migrations array above; this
// one-shot populates the column for any rows that pre-date it.
@ -1116,6 +1300,40 @@ function pruneTelemetry(deviceId) {
_delTelemetry.run(deviceId, config.statusLogPruneBatch);
}
// #240: the per-heartbeat cap above is the only thing that ever trimmed device_telemetry,
// and it only trims the device whose heartbeat is being handled — so a device that STOPS
// reporting (decommissioned, swapped, seasonally dark) leaves its rows behind forever and
// the table only ever grows. This is the matching age sweep, mirroring pruneStatusLog:
// per-device so it rides idx_telemetry_device(device_id, reported_at DESC) instead of
// scanning, chunked so a backlog trims across many bounded DELETEs, and yielding between
// devices so it can never own the loop.
//
// The retention default is deliberately LOOSER than the per-device cap (6000 rows ~= 25h
// for a device reporting every 15s) and matches the uptime report's default 30-day window,
// so this sweep cannot change a report that the row cap wasn't already truncating.
const _nextTelemetryDevice = db.prepare('SELECT device_id FROM device_telemetry WHERE device_id > ? ORDER BY device_id LIMIT 1');
const _delTelemetryOld = db.prepare('DELETE FROM device_telemetry WHERE rowid IN (SELECT rowid FROM device_telemetry WHERE device_id = ? AND reported_at < ? LIMIT ?)');
let _telemetryPruneRunning = false;
async function pruneTelemetryRetention(opts = {}) {
if (_telemetryPruneRunning) return 0;
if (opts.bandGate && config.maintenanceBandGateEnabled && currentBand() !== 'normal') return 0;
_telemetryPruneRunning = true;
try {
const batch = config.statusLogPruneBatch;
const cutoff = Math.floor(Date.now() / 1000) - Math.round(config.telemetryRetentionDays * 86400);
let total = 0, lastDev = '';
for (;;) {
const row = _nextTelemetryDevice.get(lastDev); // O(log n) seek to the next distinct device_id
if (!row) break;
lastDev = row.device_id;
total += (await chunkedDelete((lim) => _delTelemetryOld.run(lastDev, cutoff, lim).changes, { batch })).deleted;
await yieldTick(); // breathe between devices
}
if (total > 0) console.log(`[telemetry] pruned ${total} row(s) older than ${config.telemetryRetentionDays}d (per-device, batches of ${batch})`);
return total;
} catch (_) { return 0; } finally { _telemetryPruneRunning = false; }
}
// Prune old screenshots (keep only latest per device)
function pruneScreenshots(deviceId) {
const old = db.prepare(`
@ -1176,4 +1394,4 @@ try {
const { verifyAndRepairSchema } = require('../lib/schema-check');
verifyAndRepairSchema(db);
module.exports = { db, pruneTelemetry, pruneScreenshots, pruneStatusLog, getMaintenanceStats };
module.exports = { db, pruneTelemetry, pruneTelemetryRetention, pruneScreenshots, pruneStatusLog, getMaintenanceStats };

View file

@ -10,7 +10,7 @@ const { workerData, parentPort } = require('worker_threads');
const fs = require('fs');
const Database = require('better-sqlite3');
const { dbPath, intervalMs, highWaterBytes, starvationRuns } = workerData;
const { dbPath, intervalMs, highWaterBytes, starvationRuns, starvationFloorBytes, escalateCooldownMs } = workerData;
// Fault injection for TESTS ONLY (env-gated; inert in prod). Exits immediately on start so
// the controller's respawn / autocheckpoint-fallback path can be exercised deterministically.
@ -25,7 +25,9 @@ const walFile = dbPath + '-wal';
function walBytes() { try { return fs.statSync(walFile).size; } catch { return 0; } }
let lastBytes = 0;
let growthRuns = 0; // consecutive PASSIVE runs where the WAL failed to shrink
let growthRuns = 0; // consecutive PASSIVE runs where the WAL failed to shrink
let lastTruncateAt = 0; // #240: when we last blocked for a TRUNCATE (0 = never)
let coolingReported = false;
let timer = null;
function tick() {
@ -36,16 +38,47 @@ function tick() {
const bytes = walBytes();
// --- STARVATION BOUND (this is where "WAL cannot grow forever" is enforced) ---
// Either signal forces a TRUNCATE, which BLOCKS until it has checkpointed everything
// and truncated the file to 0. Blocking is fatal on the loop but FINE here on the worker.
// Escalating forces a TRUNCATE, which BLOCKS until it has checkpointed everything and
// truncated the file to 0. #240: "fine here on the worker" was only ever half true —
// the fsync is off the loop, but SQLite's locks are held across CONNECTIONS, so the
// main thread's next statement waits it out in the busy handler. Hence the gates below.
if (bytes > lastBytes) growthRuns++; else growthRuns = 0;
const overHighWater = bytes > highWaterBytes;
const starved = growthRuns >= starvationRuns;
// #240: TRUNCATE blocks ACROSS connections — the main thread's next statement waits in
// SQLite's busy handler for the whole checkpoint — so the growth signal alone must not
// be able to spend it. Two gates, because either on its own leaves the hole open:
// FLOOR: a WAL in the lower half of its budget has little to reclaim; blocking for it
// is pure cost. (Ungated, every morning fleet power-on wave bought a loop stall.)
// COOLDOWN: a WAL that already sits ABOVE the floor would otherwise escalate on every
// burst forever. However long the pressure lasts, we stall the loop at most once
// per window and let PASSIVE do the rest.
// overHighWater bypasses both — a runaway WAL is the one case worth blocking for.
const sinceLast = Date.now() - lastTruncateAt;
const starved = growthRuns >= starvationRuns && bytes >= starvationFloorBytes;
const cooling = starved && lastTruncateAt > 0 && sinceLast < escalateCooldownMs;
if (cooling && !overHighWater) {
// Report the transition only — a starved-and-cooling state persists for the whole
// window and this check runs every interval; one line, not a log flood.
if (!coolingReported) {
coolingReported = true;
post(`starvation escalation held off (WAL ${(bytes / 1e6).toFixed(1)}MB, last TRUNCATE ${Math.round(sinceLast / 1000)}s ago) — PASSIVE continues`);
}
lastBytes = bytes;
return;
}
if (overHighWater || starved) {
db.pragma('wal_checkpoint(TRUNCATE)', { simple: false });
lastTruncateAt = Date.now();
coolingReported = false;
const r = db.pragma('wal_checkpoint(TRUNCATE)', { simple: false });
const after = walBytes();
post(`escalated TRUNCATE (${overHighWater ? 'high-water' : 'starvation'}): WAL ${(bytes / 1e6).toFixed(1)}MB -> ${(after / 1e6).toFixed(1)}MB`);
// #240: TRUNCATE does NOT throw when it can't get the locks — it returns busy=1 having
// sat on SQLite's busy timeout for its full duration. Measured at ~4.9s with a single
// reader mid-transaction, reclaiming nothing, while every main-thread statement waited
// behind it. Say so plainly: a silent 5-second loss is the worst thing this can do.
const busy = Array.isArray(r) && r[0] && r[0].busy === 1;
post(`escalated TRUNCATE (${overHighWater ? 'high-water' : 'starvation'}): WAL ${(bytes / 1e6).toFixed(1)}MB -> ${(after / 1e6).toFixed(1)}MB${busy ? ' — BUSY: reclaimed nothing, blocked writers for the busy timeout' : ''}`);
growthRuns = 0;
lastBytes = after;
} else {

View file

@ -12,6 +12,7 @@
// autocheckpoint on the main connection as a degraded-but-safe fallback (occasional inline
// stall << unbounded WAL growth).
const path = require('path');
const fs = require('fs');
const { Worker } = require('worker_threads');
const config = require('../config');
@ -29,6 +30,8 @@ function spawnWorker() {
intervalMs: config.walCheckpointIntervalMs,
highWaterBytes: config.walCheckpointHighWaterMB * 1024 * 1024,
starvationRuns: config.walCheckpointStarvationRuns,
starvationFloorBytes: config.walCheckpointStarvationFloorMB * 1024 * 1024, // #240
escalateCooldownMs: config.walCheckpointEscalateCooldownMs, // #240
},
});
w.on('message', (m) => { if (m && m.log) console.log('[wal-checkpoint] ' + m.log); });
@ -77,8 +80,27 @@ function engageFallback() {
if (fallbackEngaged) return;
fallbackEngaged = true;
try { mainDb.pragma(`wal_autocheckpoint = ${config.walCheckpointFallbackPages}`); } catch (_) {}
try { mainDb.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) {} // one-time reclaim of the dead-worker backlog
console.error('[wal-checkpoint] worker unrecoverable — re-enabled inline autocheckpoint as fallback');
// #240: reclaim the dead worker's backlog, but pick the CHEAPEST form that does the job.
// The old unconditional TRUNCATE ran a blocking, fsync-heavy checkpoint on the MAIN
// thread — on slow storage a single multi-second loop stall, and one that only ever
// happens on a degraded server that can least afford it. PASSIVE reclaims what it can
// without blocking; the blocking form is reserved for a WAL that is genuinely over the
// high-water mark, where leaving it is the worse of the two risks.
const over = walBytes() > config.walCheckpointHighWaterMB * 1024 * 1024;
try { mainDb.pragma(`wal_checkpoint(${over ? 'TRUNCATE' : 'PASSIVE'})`); } catch (_) {}
console.error(`[wal-checkpoint] worker unrecoverable — re-enabled inline autocheckpoint as fallback (backlog reclaim: ${over ? 'TRUNCATE' : 'PASSIVE'})`);
}
// #240: the fallback is STICKY for the life of the process — once engaged, checkpoints are
// back on the main thread until a restart. That is exactly the shape of "it degrades with
// uptime and a restart fixes it", so it must be visible on /api/status rather than inferable
// only from a log line that may have rolled.
function getCheckpointerState() {
return { worker: !!worker, fallbackEngaged, respawns: respawnAt.length, walBytes: walBytes() };
}
function walBytes() {
try { return mainDbPath ? fs.statSync(mainDbPath + '-wal').size : 0; } catch (_) { return 0; }
}
// Call ONCE at boot, after the DB is open + migrated. `db` is the main connection (used to
@ -99,7 +121,17 @@ function startWalCheckpointer(db, dbPath) {
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) { /* best-effort */ }
worker = spawnWorker();
console.log(`[wal-checkpoint] off-thread checkpointer started (every ${config.walCheckpointIntervalMs}ms; escalate >${config.walCheckpointHighWaterMB}MB or ${config.walCheckpointStarvationRuns} growing runs; respawn max ${config.walCheckpointRespawnMax}/${config.walCheckpointRespawnWindowMs}ms)`);
// #240: this line is where an operator learns the escalation policy, so it must state ALL of
// it. It advertised only "3 growing runs" after the size floor and the cooldown were added,
// which is the half that no longer holds on its own — and reading it during an incident would
// send you looking for a checkpoint that the gates had in fact suppressed.
console.log(
`[wal-checkpoint] off-thread checkpointer started (PASSIVE every ${config.walCheckpointIntervalMs}ms; ` +
`blocking TRUNCATE when the WAL exceeds ${config.walCheckpointHighWaterMB}MB, ` +
`or after ${config.walCheckpointStarvationRuns} growing runs but only at >=${config.walCheckpointStarvationFloorMB}MB ` +
`and at most once per ${Math.round(config.walCheckpointEscalateCooldownMs / 1000)}s; ` +
`respawn max ${config.walCheckpointRespawnMax}/${config.walCheckpointRespawnWindowMs}ms)`
);
return worker;
}
@ -115,4 +147,4 @@ async function stopWalCheckpointer() {
try { await w.terminate(); } catch (_) {}
}
module.exports = { startWalCheckpointer, stopWalCheckpointer, _getWorker: () => worker };
module.exports = { startWalCheckpointer, stopWalCheckpointer, getCheckpointerState, _getWorker: () => worker };

View file

@ -0,0 +1,78 @@
'use strict';
/*
* Pending framebuffer-capture requests for BrightSign players, held for the host to collect.
*
* WHY THIS EXISTS, because it looks like a detour and is not:
*
* Every other player is TOLD to take a screenshot the server emits `device:screenshot-request`
* over the device socket and the page captures itself. A BrightSign cannot capture itself: video
* decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the
* content missing. Only the host (BrightScript) can get a real capture, via the player's own DWS.
*
* The obvious route to the host is the page: st-bridge.js posts a message over the widget's
* messageport. On real hardware (XT245, BOS 9.1.93.2) that channel is dead after page load
* instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all
* while the page was posting, though the boot-time probe round-trips. The registry is not an
* alternative either: a running BrightScript does not observe registry writes made by anyone else,
* including ones made externally through the DWS.
*
* What the host CAN do is HTTP it already fetches its own package updates that way. So the
* direction is inverted: the request waits here, and the host collects it on the loop it is
* already running. The image comes back over a plain POST, so a capture works even when the page
* is wedged, which is exactly when an operator most wants to see the screen.
*
* Deliberately in memory. A capture request is worthless a minute after it was made an operator
* clicked a button and is watching for the result so persisting it would only add a way to
* deliver a stale screenshot after a restart.
*/
// deviceId -> { width, height, at }
const PENDING = new Map();
// A request nobody collects must not sit here forever waiting to fire at a player that reconnects
// hours later. Comfortably longer than the dashboard's own 15s patience, short enough that the
// answer still refers to what the operator was looking at.
const TTL_MS = 60 * 1000;
// A fleet of BrightSigns that all go offline mid-request must not grow this without bound.
const MAX_PENDING = 500;
function request(deviceId, opts) {
if (!deviceId) return false;
const o = opts || {};
if (!PENDING.has(deviceId) && PENDING.size >= MAX_PENDING) {
// Drop the OLDEST rather than refuse the newest: the newest is the one someone is watching for.
const oldest = PENDING.keys().next().value;
if (oldest !== undefined) PENDING.delete(oldest);
}
// Re-requesting replaces rather than queues. A dashboard polling the button, or a 1fps remote
// stream, must not build a backlog the host then works through long after anyone stopped looking.
PENDING.set(deviceId, {
width: Number(o.width) > 0 ? Math.min(3840, Math.round(o.width)) : 960,
height: Number(o.height) > 0 ? Math.min(2160, Math.round(o.height)) : 540,
at: Date.now(),
});
return true;
}
/* Collect and clear. Returns null when there is nothing pending or it has expired. */
function take(deviceId) {
const p = PENDING.get(deviceId);
if (!p) return null;
PENDING.delete(deviceId);
if (Date.now() - p.at > TTL_MS) return null;
return { width: p.width, height: p.height };
}
/* Drop anything expired. Called from the same sweep as the other bounded stores. */
function sweep(now) {
const t = now || Date.now();
let dropped = 0;
for (const [id, p] of PENDING) {
if (t - p.at > TTL_MS) { PENDING.delete(id); dropped++; }
}
return dropped;
}
module.exports = { request, take, sweep, TTL_MS, MAX_PENDING, _size: () => PENDING.size };

View file

@ -1,5 +1,7 @@
'use strict';
const { preCmp } = require('./version-precedence');
/*
* Should a BrightSign player replace its own host package (autorun.zip)?
*
@ -54,7 +56,9 @@ function compareVersions(a, b) {
if (A.pre === B.pre) return 0;
if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1
if (B.pre === null) return -1;
return A.pre < B.pre ? -1 : 1; // rc1 < rc2, lexicographic is right for our naming
// Natural compare, NOT lexicographic: rc10 must outrank rc9. This file carried the same
// "lexicographic is right for our naming" assumption that broke the Android OTA path.
return preCmp(A.pre, B.pre);
}
/*

View file

@ -22,38 +22,66 @@ function safeFilename(name) {
return sanitizeString((name || '').normalize('NFC'));
}
// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content
// row. Returns the content row. Throws on a hard failure (the caller maps to 500);
// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before.
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
const id = uuidv4();
// Content-derived extension + mime. Throws UnsupportedUploadError (and removes the temp
// file) when the bytes are not a supported media type; the caller maps that to a 400.
const { filepath, mime } = finalizeUpload(file);
/*
* Everything we can learn from the BYTES: thumbnail, display dimensions, duration.
*
* Extracted so PUT /api/content/:id/replace derives them the same way an upload does. It used
* to carry its own shorter copy that handled images only so replacing a video wiped the row's
* duration, dimensions and thumbnail, and replacing a portrait photo re-introduced the EXIF
* orientation bug (#170) that the ingest path fixes with imageDisplayDims + .rotate(). A second
* copy of this logic is a second place for it to rot; there is now one.
*
* Best-effort by contract: a missing ffprobe or a decode failure yields nulls and a warning, never
* a throw the file itself is already stored and is worth more than its metadata.
*
* @returns {{width:number|null, height:number|null, durationSec:number|null, thumbnailPath:string|null}}
*/
async function deriveMediaMetadata(sourcePath, filepath, mime) {
let width = null, height = null, durationSec = null, thumbnailPath = null;
try {
// SVG is deliberately NOT handed to sharp: rasterising it goes through librsvg, which
// is where the outstanding libvips CVEs live, and an SVG is already its own thumbnail.
// SVG is deliberately NOT rasterised: it is already its own thumbnail. (It also used to be
// the one format kept away from sharp, because rasterising went through librsvg — where the
// outstanding libvips CVEs live. Nothing rasterises it now either.)
if (mime === 'image/svg+xml') {
thumbnailPath = filepath;
} else if (mime.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(file.path).metadata();
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape.
const imageOps = require('./image-ops');
const thumbName = `thumb_${filepath}`;
// Measure and thumbnail from ONE decode. Asking separately costs two, and a decode is the
// single most expensive thing on this path (~1s for a 12MP photo — unlike sharp, whose
// .metadata() only read the header). #170: rotation is implicit, the decoder auto-orients,
// so the recorded dimensions and the thumbnail agree without an explicit rotate.
const metadata = await imageOps.measureAndThumbnail(
sourcePath, path.join(config.contentDir, thumbName), config.thumbnailWidth, 70);
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape. The decoder
// applies it and reports orientation 1, so this is a no-op pass-through today — kept so the
// rule lives in one place regardless of which decoder is underneath.
({ width, height } = imageDisplayDims(metadata));
thumbnailPath = `thumb_${filepath}`;
await sharp(file.path)
.rotate() // #170: auto-orient per EXIF (and strip the tag) so the thumbnail matches
.resize(config.thumbnailWidth)
.jpeg({ quality: 70 })
.toFile(path.join(config.contentDir, thumbnailPath));
// Assign thumbnailPath only if the write actually succeeded: naming it unconditionally used
// to store a phantom thumbnail_path for a file that was never created, which the UI then
// requests forever as a broken image. The dimensions above survive that failure on purpose —
// they are independently useful, and losing them would letterbox the asset wrongly.
if (metadata.thumbnailWritten) thumbnailPath = thumbName;
else console.warn(`Thumbnail write failed for ${filepath}: ${metadata.thumbnailError}`);
} else if (mime.startsWith('video/')) {
try {
const { execFileSync } = require('child_process');
const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', file.path],
// execFile, NOT execFileSync. These two spawns each carry a 15s timeout, and run
// synchronously they block the event loop for their whole duration — nothing else on
// the server runs, including heartbeats and socket traffic. That was survivable while
// the only caller was a human-initiated upload; it stopped being survivable the moment
// a boot-time sweep started walking a whole library of them unattended, which is the
// #240 failure mode exactly (blocked loop -> missed heartbeats -> panels marked
// offline -> reconnect churn) arriving from our own maintenance.
//
// deriveMediaMetadata is already async and both callers already await it, so awaiting
// the subprocess instead of blocking on it is invisible to them.
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const { stdout: probe } = await execFileAsync('ffprobe',
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', sourcePath],
{ timeout: 15000 }
).toString();
);
const info = JSON.parse(probe);
if (info.format?.duration) durationSec = parseFloat(info.format.duration);
const videoStream = info.streams?.find(s => s.codec_type === 'video');
@ -62,11 +90,15 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
// (ffmpeg auto-rotates the thumbnail below by default, so only the dims need fixing.)
({ width, height } = videoDisplayDims(videoStream));
}
thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
// Same phantom-path discipline as the image branch above: name it only once the
// file exists, so a failed encode cannot leave the row claiming a thumbnail.
const thumbName = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
try {
execFileSync('ffmpeg', ['-y', '-i', file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)],
await execFileAsync('ffmpeg',
['-y', '-i', sourcePath, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbName)],
{ timeout: 15000 }
);
thumbnailPath = thumbName;
} catch { thumbnailPath = null; }
} catch (e) {
console.warn('ffprobe failed:', e.message);
@ -75,6 +107,18 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
} catch (e) {
console.warn('Thumbnail/metadata generation failed:', e.message);
}
return { width, height, durationSec, thumbnailPath };
}
// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content
// row. Returns the content row. Throws on a hard failure (the caller maps to 500);
// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before.
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
const id = uuidv4();
// Content-derived extension + mime. Throws UnsupportedUploadError (and removes the temp
// file) when the bytes are not a supported media type; the caller maps that to a 400.
const { filepath, mime } = finalizeUpload(file);
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(file.path, filepath, mime);
db.prepare(`
INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height, folder_id)
@ -84,4 +128,4 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
return db.prepare('SELECT * FROM content WHERE id = ?').get(id);
}
module.exports = { ingestUploadedFile, safeFilename };
module.exports = { ingestUploadedFile, safeFilename, deriveMediaMetadata };

154
server/lib/domain-verify.js Normal file
View file

@ -0,0 +1,154 @@
'use strict';
/*
* Proving that a tenant controls a sign-in domain.
*
* Per-organization SSO routes everyone at a domain to that organization's identity provider. That
* is exactly right when the organization owns the domain and an account-takeover primitive when it
* does not and typing a domain into a form is not ownership. A review demonstrated the whole
* chain: claim a company's domain, sign in as a named address there, and the real owner is left
* unable to reach an account bearing their own address.
*
* DNS is the check, because control of a domain's DNS is what "owning a domain" means in the only
* sense that matters here. It is also the mechanism every other vendor uses, so the instructions
* are already familiar to the person who has to follow them.
*
* ONE RECORD FORM a TXT record at a dedicated name:
*
* _screentinker-verify.example.com. IN TXT "st-verify=<token>"
*
* A CNAME alternative was drafted and dropped. It would have pointed at
* `<token>.verify.screentinker.com`, which requires operating a wildcard DNS zone that answers for
* every token ever issued infrastructure this project does not have, so the instructions would
* have described a check that could never pass. TXT needs nothing but the customer's own zone.
*
* A dedicated `_`-prefixed name is used rather than the apex on purpose: an apex TXT record sits
* alongside SPF and DMARC, where a careless edit breaks mail, and it is the one record set an
* administrator is most reluctant to touch.
*
* THE PROOF NAME MUST NOT BE A CNAME. A TXT lookup follows CNAMEs transparently, and RFC 4592
* means a wildcard `*.example.com` synthesizes `_screentinker-verify.example.com` too so a
* wildcard CNAME pointing anywhere the attacker controls would let them prove a domain they do not
* own. That turns an ordinary subdomain takeover into an apex takeover, and from there into every
* `@example.com` login. ACME's dns-01 permits this delegation deliberately; here the thing being
* delegated is the whole company's sign-in, so it is refused instead.
*/
const dns = require('dns').promises;
const crypto = require('crypto');
const RECORD_PREFIX = '_screentinker-verify';
const TXT_PREFIX = 'st-verify=';
// A DNS answer that never arrives must not hold an HTTP request open. The resolver's own retries
// sit under this, so it is a ceiling on the whole lookup rather than on one query.
const LOOKUP_TIMEOUT_MS = 5000;
/*
* How long an UNVERIFIED claim is worth anything.
*
* A claim reserves the domain so two tenants cannot race it but a reservation that never lapses
* is squatting with extra steps: type a company's domain, prove nothing, and hold it against its
* real owner forever. Eight hours is comfortably longer than a DNS change takes to publish and
* propagate, and short enough that an unprovable claim is gone by the next working day.
*
* The token dies with the claim. Trying again mints a NEW token, so an old record left in DNS from
* a lapsed attempt proves nothing, and a domain that changed hands cannot be verified with the
* previous holder's value.
*
* A VERIFIED domain is not affected proof already happened, and re-proving on a timer would log
* out a customer over a DNS edit made months later.
*/
const CLAIM_TTL_S = 8 * 60 * 60;
/** True when an unverified claim has run out of time and no longer reserves anything. */
function isClaimExpired(row, nowS = Math.floor(Date.now() / 1000)) {
if (!row) return false;
// `verified_at` is compared to null, NOT tested for truthiness: SQL asks `IS NOT NULL` and a
// stored 0 would otherwise be "verified" to the router and "unverified" here — two definitions of
// the same word, which is how a domain ends up routing while the code believes it cannot.
if (row.verified_at !== null && row.verified_at !== undefined) return false;
return (Number(row.token_issued_at) || 0) + CLAIM_TTL_S <= nowS;
}
/** Tokens are compared, so they are random and long enough that guessing is not a strategy. */
const newToken = () => crypto.randomBytes(16).toString('hex');
const recordName = (domain) => `${RECORD_PREFIX}.${domain}`;
/** Exactly what the admin has to publish — shown in the UI, so it is built in one place. */
function instructions(domain, token) {
return {
record_name: recordName(domain),
txt_value: `${TXT_PREFIX}${token}`,
};
}
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('DNS lookup timed out')), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
/*
* Look for the proof.
*
* Both record types are queried together and either one is enough. NXDOMAIN and "no such record"
* are ordinary answers here the overwhelmingly common case is an admin checking before the record
* has propagated so they are reported as "not found yet", never as an error to be alarmed by.
*
* Resolution uses the system resolver, which is the same view of DNS the operator already
* trusts. A tenant that can poison that resolver can forge a proof, but a tenant that can do that
* has already won something larger.
*/
async function check(domain, token) {
const name = recordName(domain);
const wantTxt = `${TXT_PREFIX}${token}`;
/*
* Refuse before looking at the TXT at all if the name is delegated. Checking afterwards would
* still be safe, but doing it first means the answer never depends on what the delegation target
* happens to say.
*/
try {
const cnames = await withTimeout(dns.resolveCname(name), LOOKUP_TIMEOUT_MS);
if (cnames && cnames.length) {
return {
ok: false,
error: `${name} is a CNAME (to ${cnames[0]}). The record must be a TXT record in this `
+ 'domain\u2019s own zone — a delegated name would let whoever controls the target prove this domain.',
};
}
} catch { /* no CNAME is the normal and wanted case */ }
let records;
try {
records = await withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS);
} catch (e) {
// NXDOMAIN and "no such record" are the ORDINARY answers here — an admin checking before the
// record has propagated — so they are "not found yet", not an error to be alarmed by.
if (/timed out/i.test(e.message)) return { ok: false, error: 'the DNS lookup timed out — try again shortly' };
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
}
// resolveTxt returns arrays of string chunks — a value over 255 bytes is split, so join first.
for (const chunks of records) {
if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' };
}
// Present but wrong is a different problem from absent, and the fixes differ: one needs
// correcting, the other needs publishing. A wildcard TXT lands here, which is right — it answers
// with its own value, and that is not a proof of anything. (A wildcard CNAME is refused above.)
if (records.length) {
const found = records.map((c) => c.join('')).join('; ');
return { ok: false, error: `${name} exists but does not match. Found: ${found}` };
}
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
}
module.exports = {
check, instructions, newToken, recordName, isClaimExpired,
CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX,
};

View file

@ -0,0 +1,142 @@
'use strict';
/*
* Pure-JavaScript image operations the two things the ingest path ever asked sharp for:
* measure an image, and write a thumbnail.
*
* THIS FILE IS THE WORK, NOT THE ENTRY POINT. Callers use ./image-ops, which runs these on a
* worker thread; everything here is CPU-bound pure JS that would otherwise stall the event loop
* for ~1s per 12MP photo. Requiring this module directly is only correct inside the worker (or in
* image-ops' inline fallback). See ./image-ops for why.
*
* WHY NOT SHARP: sharp is a native module wrapping libvips. That costs us a prebuilt binary per
* platform/ABI, and when there isn't one (or Node moves ABI) the failure is
* ERR_DLOPEN_FAILED/NODE_MODULE_VERSION at require time the same class of breakage
* lib/preflight-deps.js exists to explain for better-sqlite3. Nothing in here is native, so the
* server runs anywhere Node runs, including the embedded targets that have no toolchain.
*
* FORMAT COVERAGE vs the sharp it replaces:
* jpeg png gif tiff bmp Jimp, natively
* webp avif @jsquash/* WebAssembly, bundled, no network (see wasmDecode below)
* svg never reaches here; callers thumbnail an SVG with itself
* heic unsupported and it already was. sharp lists `heif`, but its
* prebuilt libvips has AV1 only and refuses HEVC ("Unsupported
* compression"), so .heic uploads have never produced a thumbnail.
*
* ORIENTATION (#170): Jimp applies EXIF orientation when it decodes and rewrites the tag to 1,
* so what comes back is already DISPLAY dimensions the rotation sharp needed an explicit
* .rotate() for. metadata() therefore reports orientation 1 and lets imageDisplayDims() run as a
* no-op rather than swapping W/H a second time. Report the tag honestly and that helper stays
* correct for any future decoder that does NOT auto-orient.
*/
const path = require('path');
const fs = require('fs');
const { sniffMime } = require('./upload-sniff');
// Jimp is ESM-first but ships a CJS entry; require() is fine and keeps this file loadable from
// the CommonJS server. Deferred so a caller that never touches an image never pays for it.
let _jimp = null;
function jimp() {
if (!_jimp) _jimp = require('jimp');
return _jimp;
}
/*
* @jsquash's decoders are browser-first: they locate their .wasm with
* `fetch(new URL('...wasm', import.meta.url))`. Under Node that URL is a file:// one and Node's
* fetch does not implement file://, so the bundled binary never loads and the only symptom is a
* bare "fetch failed". The binary IS on disk in the package read and compile it ourselves, then
* hand the Module to init(). No network, at install time or after.
*/
const WASM_CODECS = {
'image/webp': { pkg: '@jsquash/webp', wasm: '@jsquash/webp/codec/dec/webp_dec.wasm' },
'image/avif': { pkg: '@jsquash/avif', wasm: '@jsquash/avif/codec/dec/avif_dec.wasm' },
};
const decoderCache = new Map();
async function wasmDecode(mime, buf) {
const spec = WASM_CODECS[mime];
if (!spec) return null;
if (!decoderCache.has(mime)) {
decoderCache.set(mime, (async () => {
const mod = await import(`${spec.pkg}/decode.js`);
await mod.init(await WebAssembly.compile(fs.readFileSync(require.resolve(spec.wasm))));
return mod.default;
})());
}
const decode = await decoderCache.get(mime);
return decode(buf); // -> ImageData-ish { data, width, height }
}
/*
* Decode to a Jimp image whatever the format. Reuses sniffMime rather than carrying a second copy
* of the magic-byte table routes/media.js already duplicating it once is noted there as a smell.
* Throws on anything undecodable, which is the contract callers already handle (a failure yields
* null metadata and no thumbnail, never a lost upload).
*/
async function readImage(src) {
const buf = await fs.promises.readFile(src);
const mime = sniffMime(buf);
if (WASM_CODECS[mime]) {
const raw = await wasmDecode(mime, buf);
if (!raw) throw new Error(`no decoder for ${mime}`);
return jimp().Jimp.fromBitmap({ data: Buffer.from(raw.data), width: raw.width, height: raw.height });
}
return jimp().Jimp.read(buf);
}
/*
* Display dimensions, shaped like the sharp metadata the callers already destructure.
* orientation is 1 because the decode above already applied it see ORIENTATION note at the top.
*/
async function metadata(src) {
const img = await readImage(src);
return { width: img.bitmap.width, height: img.bitmap.height, orientation: 1 };
}
/*
* Resize-and-encode an ALREADY DECODED image. Never upscales: sharp's resize() would enlarge a
* small source, but a thumbnail bigger than its original is pure waste and callers only shrink.
* Mutates img, so measure before calling.
*/
async function encodeThumbnail(img, destPath, width, quality) {
if (img.bitmap.width > width) img.resize({ w: width });
await fs.promises.writeFile(destPath, await img.getBuffer('image/jpeg', { quality }));
}
/*
* Write a JPEG thumbnail `width` px wide, aspect preserved sharp's
* .rotate().resize(width).jpeg({quality}).toFile(). Rotation is implicit in the decode.
*/
async function writeThumbnail(src, destPath, width, quality = 70) {
await encodeThumbnail(await readImage(src), destPath, width, quality);
}
/*
* Measure AND thumbnail from a SINGLE decode what ingest actually wants.
*
* Calling metadata() then writeThumbnail() decodes the file twice. That was free under sharp,
* whose .metadata() only parses the header, but here every decode is the full ~1s of a 12MP
* photo, so the naive pairing doubled the most expensive thing the ingest path does.
*
* A thumbnail failure must NOT discard the dimensions: they are independently useful (the player
* needs them to letterbox correctly) and that is how the two-call version behaved, since width and
* height were already assigned before the thumbnail was written. So the write is reported, not
* thrown and the caller assigns a thumbnail_path only when thumbnailWritten is true, keeping the
* phantom-path discipline that stops the UI requesting a file that was never created.
* A DECODE failure still throws: there is nothing to report about an unreadable image.
*/
async function measureAndThumbnail(src, destPath, width, quality = 70) {
const img = await readImage(src);
const measured = { width: img.bitmap.width, height: img.bitmap.height, orientation: 1 };
try {
await encodeThumbnail(img, destPath, width, quality);
return { ...measured, thumbnailWritten: true, thumbnailError: null };
} catch (err) {
return { ...measured, thumbnailWritten: false, thumbnailError: err && err.message ? err.message : String(err) };
}
}
module.exports = { metadata, writeThumbnail, measureAndThumbnail, readImage };

View file

@ -0,0 +1,30 @@
'use strict';
/*
* Worker-thread host for image-ops-core. One job per message, one reply per job, keyed by id.
*
* Deliberately thin: every decision (queueing, lifecycle, fallback) lives in ../lib/image-ops so
* there is one place to reason about them. This end only does the work and reports what happened.
*
* Errors come back as a message rather than a thrown exception, so one undecodable upload does
* not tear down the worker and take the queued jobs of unrelated callers with it.
*/
const { parentPort } = require('worker_threads');
const core = require('./image-ops-core');
const OPS = {
metadata: (job) => core.metadata(job.src),
writeThumbnail: (job) => core.writeThumbnail(job.src, job.dest, job.width, job.quality),
measureAndThumbnail: (job) => core.measureAndThumbnail(job.src, job.dest, job.width, job.quality),
};
parentPort.on('message', async (job) => {
try {
const op = OPS[job.op];
if (!op) throw new Error(`unknown image op: ${job.op}`);
parentPort.postMessage({ id: job.id, ok: true, result: await op(job) });
} catch (err) {
parentPort.postMessage({ id: job.id, ok: false, error: err && err.message ? err.message : String(err) });
}
});

147
server/lib/image-ops.js Normal file
View file

@ -0,0 +1,147 @@
'use strict';
/*
* Image operations, off the main thread.
*
* WHY THIS EXISTS: image-ops-core is pure JavaScript, so unlike the native sharp it replaced
* which handed work to a libvips threadpool its CPU cost lands on whatever thread calls it. A
* 12MP photo measures at ~1.0s of solid, uninterruptible main-thread work. That is not a slow
* upload, it is a stalled event loop: no heartbeats, no socket traffic, nothing. lib/thumbnail-
* backfill.js walks an entire content library at boot, so in-process it reproduces #240 exactly
* (blocked loop -> missed heartbeats -> panels marked offline -> reconnect churn), arriving from
* our own maintenance. The same reasoning already moved this file's video branch from
* execFileSync to execFile; this is that fix for the image branch.
*
* The work is therefore hosted on a worker thread and this module is the only entry point.
*
* ONE JOB AT A TIME, deliberately. Decoding holds a full RGBA bitmap a 12MP photo is ~48MB so
* letting jobs overlap multiplies peak memory by the queue depth, which is exactly the wrong
* failure on the small targets this whole change is meant to reach. Serialized, the ceiling is one
* image regardless of how many uploads land at once. It also costs nothing in throughput: the work
* is CPU-bound, and a single busy worker already saturates the core it runs on.
*
* The worker is unref'd while idle so it never holds the process open scripts/backfill-rotation-
* dims.js is a CLI that must exit, and `node --test` would otherwise hang forever and ref'd only
* while a job is in flight, so an in-progress thumbnail cannot be cut short by the process exiting.
*/
const path = require('path');
const WORKER_PATH = path.join(__dirname, 'image-ops-worker.js');
const IDLE_SHUTDOWN_MS = 60_000; // release the decoder heap (jimp + the WASM codecs) when quiet
let worker = null;
let idleTimer = null;
let inFlight = null; // { id, resolve, reject } — at most one, by design
let inlineOnly = false; // set if a worker cannot be created at all; see runInline
let nextId = 1;
const queue = [];
function clearIdleTimer() {
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
}
function scheduleIdleShutdown() {
clearIdleTimer();
if (!worker || inFlight || queue.length) return;
idleTimer = setTimeout(() => {
idleTimer = null;
if (worker && !inFlight && !queue.length) { const w = worker; worker = null; w.terminate(); }
}, IDLE_SHUTDOWN_MS);
idleTimer.unref?.();
}
// Reject everything outstanding. Called when the worker dies underneath us — a crash means OOM or
// a bug, not a bad image (image-ops-worker catches decode failures and replies normally), so there
// is nothing to usefully retry and callers already treat a rejection as "no metadata".
function failAll(reason) {
const dead = [inFlight, ...queue].filter(Boolean);
inFlight = null;
queue.length = 0;
for (const job of dead) job.reject(new Error(reason));
}
function ensureWorker() {
if (worker) return worker;
const { Worker } = require('worker_threads');
worker = new Worker(WORKER_PATH);
worker.unref();
worker.on('message', (msg) => {
const job = inFlight;
if (!job || job.id !== msg.id) return; // a reply from a terminated generation; ignore
inFlight = null;
if (msg.ok) job.resolve(msg.result); else job.reject(new Error(msg.error));
pump();
});
worker.on('error', (err) => { worker = null; failAll(`image worker failed: ${err.message}`); });
worker.on('exit', (code) => {
worker = null;
if (inFlight || queue.length) failAll(`image worker exited (code ${code})`);
});
return worker;
}
function pump() {
if (inFlight) return;
if (!queue.length) { worker?.unref(); scheduleIdleShutdown(); return; }
clearIdleTimer();
inFlight = queue.shift();
const w = ensureWorker();
w.ref(); // a job is running: hold the process open until it finishes
w.postMessage(inFlight.job);
}
// Last resort: if worker_threads cannot give us a thread at all, do the work in-process rather
// than refuse to thumbnail. Stalls the loop — that is the bug this module exists to avoid — so it
// is announced rather than silent.
async function runInline(job) {
const core = require('./image-ops-core');
return job.op === 'metadata'
? core.metadata(job.src)
: core.writeThumbnail(job.src, job.dest, job.width, job.quality);
}
function submit(job) {
if (inlineOnly) return runInline(job);
job.id = nextId++;
return new Promise((resolve, reject) => {
try {
ensureWorker();
} catch (err) {
inlineOnly = true;
console.warn(`[image-ops] no worker thread (${err.message}) — decoding in-process, which blocks the event loop`);
return resolve(runInline(job));
}
queue.push({ id: job.id, job, resolve, reject });
pump();
});
}
/* Display dimensions, shaped like the sharp metadata callers destructure. See image-ops-core. */
function metadata(src) {
return submit({ op: 'metadata', src });
}
/* Write a JPEG thumbnail `width` px wide, aspect preserved. Rotation is implicit in the decode. */
function writeThumbnail(src, dest, width, quality = 70) {
return submit({ op: 'writeThumbnail', src, dest, width, quality });
}
/*
* Both of the above from ONE decode -> { width, height, orientation, thumbnailWritten,
* thumbnailError }. Prefer this wherever both are wanted: a decode here is the full ~1s of a 12MP
* photo, not sharp's cheap header parse, so the pair costs double. See image-ops-core.
*/
function measureAndThumbnail(src, dest, width, quality = 70) {
return submit({ op: 'measureAndThumbnail', src, dest, width, quality });
}
/* Drop the worker now rather than waiting out the idle timer. For shutdown paths and tests. */
async function shutdown() {
clearIdleTimer();
const w = worker;
worker = null;
if (w) await w.terminate();
}
module.exports = { metadata, writeThumbnail, measureAndThumbnail, shutdown };

View file

@ -0,0 +1,39 @@
'use strict';
// #237: a video added to a playlist got the flat 10s default, so a 32s clip was cut off at
// 10s unless the operator looked up the runtime and typed it in — per item, every time. The
// content row already carries the probed length, so that becomes the default. Shared by
// every playlist_items insert path (dashboard, device assign, group assign, agency portal,
// schedules, public API) because the operator sees one product, not six routes.
const DEFAULT_ITEM_DURATION = 10;
// A probe that reports longer than this is a broken container (streams and truncated files
// report absurd or near-infinite lengths), not a clip anyone means to schedule — honoring it
// would park a display on one item for days with no obvious cause. 12h.
const MAX_CONTENT_DURATION = 43200;
// The content's own length, or null when there isn't a trustworthy one: images, widgets,
// YouTube and remote-URL rows carry no duration, and a failed ffprobe leaves null/0. Rounded
// UP so a 31.7s clip gets 32 and not a 31 that clips the tail; whole seconds because the
// Android player reads duration_sec with optInt (a fractional value silently truncates) and
// an operator expects to see a round number in the duration box.
function contentDefaultDuration(content) {
const n = Number(content && content.duration_sec);
if (!Number.isFinite(n) || n <= 0 || n > MAX_CONTENT_DURATION) return null;
return Math.max(1, Math.ceil(n));
}
// The duration to STORE on a new playlist_item. An explicit operator value always wins; the
// content's own length is only a default for when none was given.
//
// Anything that isn't a usable number falls back rather than reaching the DB: a duration of
// 0 (or NaN, from a client that sent a string) makes the players schedule a 0ms advance,
// which self-loops and black-screens the TV (#widget zero-duration loop).
function resolveItemDuration(requested, content) {
const n = Number(requested);
if (Number.isFinite(n) && n >= 1) return Math.floor(n);
return contentDefaultDuration(content) ?? DEFAULT_ITEM_DURATION;
}
module.exports = { resolveItemDuration, contentDefaultDuration, DEFAULT_ITEM_DURATION, MAX_CONTENT_DURATION };

View file

@ -55,6 +55,42 @@ function captureIdentity(data) {
};
}
/*
* Absent is not a statement the same rule applyCapabilities() enforces for the capability column.
*
* captureIdentity above coerces a MISSING platform to the literal 'unknown', and persistIdentity
* used to write that straight over the stored value. One register from a client that doesn't send
* the field an older build after an OTA, a downgrade, anything pre-v4 permanently erased the
* panel's platform.
*
* That column is load-bearing, not decorative: player-capabilities.platformFamily() reads it to
* pick a baseline. An erased Tizen panel falls through to the WEB baseline and is offered a volume
* slider the .wgt has no handler for the exact control BASELINE.tizen exists to hide while an
* erased BrightSign loses screen power and reboot and gains screenshots it cannot take.
*
* platform and client_type are preserved; client_version and contract_version are NOT. The split is
* "physical fact" vs "property of the build currently installed": a panel does not stop being a
* Tizen TV or a .wgt player, but its version and protocol level change with every OTA, and there
* "we no longer know" is the truthful answer rather than a stale number.
*
* client_type earns its place because it is the SECOND signal platformFamily() reads ('wgt' => a
* Tizen TV): preserving platform while letting client_type decay to 'legacy' would leave a panel
* with no identifying signal at all.
*
* @param {object|null} stored the identity row currently in the DB
* @param {object} incoming the freshly captured identity (mutated in place and returned)
*/
const IDENTITY_PLACEHOLDER = { platform: 'unknown', client_type: 'legacy' };
function preserveKnownIdentity(stored, incoming) {
if (!incoming || !stored) return incoming;
for (const [field, placeholder] of Object.entries(IDENTITY_PLACEHOLDER)) {
if (incoming[field] === placeholder && stored[field] && stored[field] !== placeholder) {
incoming[field] = stored[field];
}
}
return incoming;
}
// A1 change-detection: has the (already-captured) identity changed vs what's stored? A genuine
// reconnect with an unchanged identity (the common case) then does NO write. A never-stored device
// (current null / all-NULL columns) or a real change (e.g. new client_version after an OTA) writes.
@ -78,4 +114,4 @@ function sanitizeExitReason(reason, detail) {
return { reason, detail: d };
}
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, preserveKnownIdentity, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };

32
server/lib/media-tools.js Normal file
View file

@ -0,0 +1,32 @@
'use strict';
// Availability probe for the external media binaries (ffmpeg/ffprobe) that video
// thumbnail + duration extraction depends on. They are SYSTEM dependencies, not npm
// ones, so a deployment can easily lack them — and content-ingest's best-effort
// contract means every video then uploads fine but silently gets no thumbnail and
// no duration. Probed once and cached: the answer can't change without an operator
// installing packages, which comes with a restart anyway.
//
// Async on purpose: the first caller is server.js right after listen, and a hung
// binary (NFS-mounted PATH shim, broken wrapper) must degrade to a late log line,
// not block request serving on a freshly-bound port.
const { execFile } = require('child_process');
let cached = null;
function probeTool(bin) {
return new Promise((resolve) => {
execFile(bin, ['-version'], { timeout: 5000 }, (err) => resolve(!err));
});
}
function mediaToolStatus() {
if (!cached) {
cached = Promise.all([probeTool('ffmpeg'), probeTool('ffprobe')])
.then(([ffmpeg, ffprobe]) => ({ ffmpeg, ffprobe }));
}
return cached;
}
module.exports = { mediaToolStatus };

View file

@ -0,0 +1,471 @@
'use strict';
/*
* Which identity providers this instance offers.
*
* Providers are resolved through ONE function on purpose. Instance-wide providers come from the
* environment today; per-organization SSO will come from the database later, and when it does it
* plugs in here rather than growing a second login path. The rest of the app only ever asks
* "give me the provider called X" and never learns where the answer came from.
*
* Configuration
*
* OIDC_PROVIDERS=okta,authentik comma-separated slugs to enable
* OIDC_OKTA_ISSUER=https://example.okta.com
* OIDC_OKTA_CLIENT_ID=...
* OIDC_OKTA_CLIENT_SECRET=... optional PKCE means a public client works
* OIDC_OKTA_NAME=Okta optional button label
* OIDC_OKTA_SCOPES=openid email profile optional
*
* Google and Microsoft are ordinary OIDC providers and are registered automatically from the
* variables the README has always documented (GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID +
* MICROSOFT_TENANT_ID), so an existing deployment keeps working without editing anything. They get
* no special code path the only difference is that their issuer is filled in for you.
*/
const GOOGLE_ISSUER = 'https://accounts.google.com';
const DEFAULT_SCOPES = 'openid email profile';
/** A slug has to be safe in a URL path and in an env var name. */
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/;
/*
* `local` is what users.auth_provider says for a password account, so a provider by that name would
* make every federated login look like a password login to the linking rules and would put a NULL
* password_hash on rows that POST /login then feeds straight to bcrypt.compareSync. Reserved rather
* than merely discouraged.
*/
const RESERVED_SLUGS = new Set(['local', 'recovery']);
function envKey(slug, suffix) {
return `OIDC_${slug.toUpperCase().replace(/-/g, '_')}_${suffix}`;
}
function fromEnv(env, slug) {
const issuer = (env[envKey(slug, 'ISSUER')] || '').trim().replace(/\/+$/, '');
const clientId = (env[envKey(slug, 'CLIENT_ID')] || '').trim();
if (!issuer || !clientId) return null;
return {
slug,
name: (env[envKey(slug, 'NAME')] || '').trim() || slug.replace(/[-_]/g, ' '),
issuer,
clientId,
clientSecret: (env[envKey(slug, 'CLIENT_SECRET')] || '').trim() || null,
scopes: (env[envKey(slug, 'SCOPES')] || '').trim() || DEFAULT_SCOPES,
// Escape hatch for an IdP that verifies addresses but does not say so in the token. Off unless
// the operator sets it, and it only ever covers an ABSENT claim — see emailIsVerified().
assumeEmailVerified: /^(1|true|yes)$/i.test((env[envKey(slug, 'ASSUME_EMAIL_VERIFIED')] || '').trim()),
source: 'env',
};
}
/**
* May this provider's assertion of `email` be treated as verified?
*
* The login callback used to demand `claims.email_verified === true` outright. That is correct for a
* provider a CUSTOMER configured such a provider is chosen by the party it vouches for, so
* anything it merely asserts is worth nothing but it made Microsoft sign-in impossible, because
* **Entra ID v2 does not emit the claim at all**. Every Entra login authenticated successfully and
* was then refused with `email_unverified`.
*
* The distinction that resolves it: `users.email_verified` is OUR state and this is the IdP's claim.
* Whether an address is trustworthy is a decision about WHO WE TRUSTED, not a field we can insist a
* provider populate. An instance-wide provider was chosen by the operator the same trust that
* already exempts it from domain confinement and the Microsoft entry is additionally pinned to one
* tenant GUID, so only that directory can issue tokens for it.
*
* An organization's own provider may assume too, but only once it has DNS-verified a domain the
* callback confines it to those domains, so it can only ever speak for names it proved it controls.
* Requiring the claim from it as well meant a customer's Entra tenant went green on domain
* verification and then failed the login anyway.
*
* Three limits keep this from becoming the hole the strict check was closing:
* - an EXPLICIT `email_verified: false` is always refused. Assuming only ever covers an omitted
* claim, never a provider actively saying the address is unverified;
* - for an org provider it is DERIVED from proof (`verified.length > 0`) and is never a column, so
* a customer cannot switch it on for themselves;
* - domain confinement is unchanged and still runs first, so an org provider that assumes still
* cannot assert an address outside a domain it has proven.
*/
function emailIsVerified(claims, provider) {
const asserted = (claims || {}).email_verified;
if (asserted === true) return true;
if (asserted === undefined || asserted === null) return !!(provider && provider.assumeEmailVerified);
return false; // explicit false, or anything else the provider chose to send
}
/**
* Every provider this instance offers, in a stable order.
*
* Never returns clientSecret to a caller that only wants to draw buttons see publicList().
*/
function list(env = process.env) {
const out = [];
const seen = new Set();
// Back-compat: the two providers the README documented before generic OIDC existed.
const googleId = (env.GOOGLE_CLIENT_ID || '').trim();
if (googleId) {
out.push({
slug: 'google',
name: 'Google',
issuer: GOOGLE_ISSUER,
clientId: googleId,
clientSecret: (env.GOOGLE_CLIENT_SECRET || '').trim() || null,
scopes: DEFAULT_SCOPES,
// Google DOES send email_verified. Nothing to assume, so it stays strict.
assumeEmailVerified: false,
source: 'env',
});
seen.add('google');
}
const msId = (env.MICROSOFT_CLIENT_ID || '').trim();
if (msId) {
/*
* A TENANT GUID IS REQUIRED. `common` and `organizations` are refused, for two reasons that
* point the same way.
*
* It does not work: Microsoft's multi-tenant metadata advertises
* `https://login.microsoftonline.com/{tenantid}/v2.0` a literal template so the issuer can
* never equal the configured URL and every login fails at /start regardless.
*
* And the obvious patch is dangerous: loosening the `iss` comparison to accept the template
* means accepting tokens from EVERY Azure tenant, which is nOAuth an admin of any tenant can
* set an arbitrary, unverified `email` on one of their own users and be issued a session as that
* address here. Doing multi-tenant Microsoft safely needs per-tenant pinning (validate `tid`
* against an allowlist and key the account on `oid`+`tid`, not on email), which is a feature,
* not a relaxed regex.
*
* So: refuse loudly at boot rather than ship a login that either never works or works too well.
*/
const rawTenant = (env.MICROSOFT_TENANT_ID || '').trim().toLowerCase();
if (!rawTenant || ['common', 'organizations', 'consumers'].includes(rawTenant)) {
if (!list._warned) {
console.warn('[sso] MICROSOFT_CLIENT_ID is set but MICROSOFT_TENANT_ID is missing or multi-tenant '
+ `(${rawTenant || 'unset'}). Microsoft sign-in is DISABLED: set your tenant GUID. See README.`);
list._warned = true;
}
seen.add('microsoft');
} else {
out.push({
slug: 'microsoft',
name: 'Microsoft',
// A tenant GUID narrows the issuer to that tenant, so a token from any other tenant fails
// the `iss` check instead of being quietly accepted.
issuer: `https://login.microsoftonline.com/${rawTenant}/v2.0`,
clientId: msId,
clientSecret: (env.MICROSOFT_CLIENT_SECRET || '').trim() || null,
scopes: DEFAULT_SCOPES,
/*
* Entra ID v2 never sends email_verified, so demanding it refused every Microsoft login.
* Safe here specifically because this entry is operator-chosen AND pinned to one tenant
* GUID above: only that directory can issue a token whose `iss` matches. An explicit
* email_verified:false is still refused see emailIsVerified().
*/
assumeEmailVerified: true,
source: 'env',
});
seen.add('microsoft');
}
}
for (const raw of String(env.OIDC_PROVIDERS || '').split(',')) {
const slug = raw.trim().toLowerCase();
if (!slug || seen.has(slug)) continue;
if (!SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug)) continue; // ignore rather than crash a boot over a typo
const p = fromEnv(env, slug);
if (p) { out.push(p); seen.add(slug); }
}
return out;
}
/** One provider by slug, or null. This is the seam per-org SSO will extend. */
function get(slug, env = process.env) {
if (!slug || !SLUG_RE.test(String(slug))) return null;
const fromEnvList = list(env).find((p) => p.slug === slug);
if (fromEnvList) return fromEnvList;
// Instance providers win a name clash, which cannot happen in practice (org slugs are random)
// but decides it deterministically if it ever did.
return getOrgProvider(slug);
}
/**
* What the login page is allowed to know: enough to draw a button and nothing else.
* No client ids, because the browser never talks to the provider directly any more the redirect
* is built server-side, so there is nothing for the page to do with one.
*/
function publicList(env = process.env) {
return list(env).map((p) => ({ slug: p.slug, name: p.name }));
}
/*
* Per-organization providers.
*
* Loaded lazily so this module stays usable (and testable) without a database the env-only paths
* above never touch it. An org provider is an ordinary provider once loaded: the login flow cannot
* tell the difference, which is the whole point of resolving everything through get().
*/
let _db = null;
function db() {
if (_db === null) {
try { _db = require('../db/database').db; } catch { _db = false; }
}
return _db || null;
}
function rowToProvider(row, secretbox) {
// Resolved once: it decides both which addresses this provider may assert and, below, whether it
// has proven anything at all.
const verified = verifiedDomainsFor(row.id);
return {
slug: row.slug,
name: row.name,
issuer: String(row.issuer).replace(/\/+$/, ''),
clientId: row.client_id,
/*
* Fail CLOSED. secretbox.decrypt returns null when the key has rotated, which silently turned a
* confidential client into a public one the login then fails at the provider with an error
* nobody can act on, while the admin screen still says "a secret is set".
*/
clientSecret: row.client_secret_enc
? (secretbox.decrypt(row.client_secret_enc) ?? (() => { throw new Error('client secret could not be decrypted — re-enter it'); })())
: null,
scopes: row.scopes || DEFAULT_SCOPES,
/*
* DERIVED from proof, never read from a column.
*
* Entra ID v2 omits email_verified, so demanding it refused every customer who brought their own
* Microsoft tenant the domain went green and the login still failed. Requiring a claim
* Microsoft does not send is not a security control, it is an outage.
*
* What makes it safe to stop requiring it is the proof that already gates this provider: the
* callback confines it to DNS-verified domains, and an address is only reached here after
* passing that. Whoever controls a domain's DNS controls its mail, which is the same trust that
* makes a verification link meaningful in the first place.
*
* So the assumption is tied to having proven SOMETHING. A provider with no verified domain
* assumes nothing belt and braces, because emailAllowedForProvider already refuses it (an
* empty allow-list matches no domain), and this way a future refactor that reorders those checks
* cannot silently widen it.
*
* Still never a column. An org must not be able to switch this on for itself; it is a
* consequence of DNS proof, not a setting.
*/
assumeEmailVerified: verified.length > 0,
source: 'org',
organizationId: row.organization_id,
/*
* VERIFIED domains only never org_sso_providers.email_domains.
*
* That column is what an admin typed. This is what they PROVED, by publishing a record in the
* domain's own DNS, and it is the only thing the login callback may confine an assertion to.
* Reading the typed column here would reduce the whole verification feature to a decoration:
* a tenant could type any company's domain and immediately assert addresses in it.
*/
emailDomains: verified.join(','),
};
}
/** The domains a provider has actually proved it controls. */
function verifiedDomainsFor(providerId) {
const conn = db();
if (!conn) return [];
try {
return conn.prepare('SELECT domain FROM org_sso_domains WHERE provider_id = ? AND verified_at IS NOT NULL')
.all(providerId).map((r) => r.domain);
} catch (e) {
if (/no such table/i.test(e.message)) return [];
throw e;
}
}
/** One org provider by its (globally unique) slug, or null. */
function getOrgProvider(slug) {
const conn = db();
if (!conn || !slug || !SLUG_RE.test(String(slug))) return null;
try {
const row = conn.prepare('SELECT * FROM org_sso_providers WHERE slug = ? AND enabled = 1').get(String(slug));
if (!row) return null;
return rowToProvider(row, require('./secretbox'));
} catch (e) {
/*
* Only "the table is not there yet" is a null. This catch used to swallow EVERYTHING, which
* turned a secret that could not be decrypted back into a silent success the exact failure the
* fail-closed check above exists to prevent. Anything else propagates so it is logged and the
* login fails loudly.
*/
if (/no such table/i.test(e.message)) return null;
throw e;
}
}
/**
* Who owns a provider slug without decrypting anything, and regardless of whether it is enabled.
*
* The linking rules need to know which ORGANIZATION established an account, not how to talk to its
* provider, and asking get() for that has two problems: it fails closed on an undecryptable secret
* (right for a login, wrong for an ownership question) and it hides disabled rows, which still own
* the accounts they created.
*
* null means "nothing here owns that slug" either it never existed or the provider has since been
* deleted, and those are deliberately the same answer.
*/
function ownerOf(slug) {
if (!slug || !SLUG_RE.test(String(slug))) return null;
if (list().some((p) => p.slug === slug)) return { source: 'env', organizationId: null };
const conn = db();
if (!conn) return null;
try {
const row = conn.prepare('SELECT organization_id FROM org_sso_providers WHERE slug = ?').get(String(slug));
return row ? { source: 'org', organizationId: row.organization_id } : null;
} catch (e) {
if (/no such table/i.test(e.message)) return null;
throw e;
}
}
/**
* Which provider, if any, owns an email address.
*
* Domain routing is what makes per-org SSO usable: a customer's staff type their work address and
* are sent to their own identity provider rather than being asked for a password they do not have.
*
* Matched on the domain ONLY, never on whether the address exists. Answering "yes, that domain
* uses SSO" tells an attacker nothing they could not learn from the customer's website; answering
* "yes, that USER exists" would be an account-enumeration oracle on the login page.
*/
function forEmail(email) {
const conn = db();
if (!conn) return null;
const at = String(email || '').lastIndexOf('@');
if (at === -1) return null;
const domain = String(email).slice(at + 1).toLowerCase().trim();
if (!domain) return null;
try {
/*
* Routing is driven by the VERIFIED domain table, not by the text an admin typed, and the JOIN
* is what enforces it an unverified claim cannot send anyone anywhere.
*
* No ORDER BY: `domain` is UNIQUE, so at most one row can match and there is no tie to break.
* An earlier version ordered here and the comment claimed it decided a race; it decided
* nothing, and saying so invited someone to rely on it.
*/
const row = conn.prepare(`
SELECT p.* FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1
`).get(domain);
if (row) return rowToProvider(row, require('./secretbox'));
} catch (e) {
// Only a missing table is a null — anything else (a secret that will not decrypt, a schema
// drift) must surface rather than silently answering "this domain has no SSO", which is how a
// fail-closed guarantee turns back into a fail-open one.
if (!/no such table/i.test(e.message)) throw e;
}
return null;
}
/**
* Is this address inside an organization that REQUIRES its identity provider?
*
* Only a VERIFIED domain can compel anyone: an org must not be able to switch off password login
* for a domain it merely typed, which would be a denial-of-service against a company it has nothing
* to do with. Enabled providers only, for the same reason a disabled provider routes nobody.
*/
function ssoOnlyForEmail(email) {
const conn = db();
if (!conn) return null;
const at = String(email || '').lastIndexOf('@');
if (at === -1) return null;
// A trailing root dot is the same domain; `acme.test.` slipped the match and let someone
// register at an SSO-only domain (a distinct string, so no squat — but a hole in the gate).
const domain = String(email).slice(at + 1).toLowerCase().trim().replace(/\.+$/, '');
if (!domain) return null;
try {
return conn.prepare(`
SELECT o.id AS organization_id, o.name AS organization_name, p.slug
FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
JOIN organizations o ON o.id = d.organization_id
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1 AND o.sso_only = 1
`).get(domain) || null;
} catch (e) {
/*
* FAIL CLOSED. This used to swallow `no such column` and return null and null means "not
* SSO-only", i.e. password login proceeds. It is the single control stopping a password from
* bypassing a customer's identity provider, so a schema problem must never be the thing that
* quietly switches it off. The sibling forEmail() carries the same warning for the same reason.
*
* `no such table` on the DOMAINS table is different and genuinely means "this instance has no
* per-org SSO at all", so it stays a null.
*/
/*
* "The feature is not installed" and "the schema drifted" are different answers.
*
* A missing per-org SSO table, or no organizations table at all, means this instance has no
* per-organization SSO nothing is being bypassed, so null is correct and a single-tenant
* install must keep working. A missing sso_only COLUMN on a table that does exist is drift, and
* that is the case that must never quietly answer "not required".
*/
if (/no such table: (org_sso_domains|org_sso_providers|organizations|organization_members)/i.test(e.message)) return null;
console.error('[sso] could not determine SSO-only status, refusing password login:', e.message);
throw e;
}
}
/**
* Must THIS USER use single sign-on?
*
* Membership, not just the address. ssoOnlyForEmail() answers about a DOMAIN, and a review used
* that gap to walk straight in: any account in the tenant whose address sits outside the verified
* domains kept password login a contractor, an MSP, the one address nobody remembered. Worse, it
* could be manufactured on demand, because an org admin can create a local password account at any
* address and bind it to their workspace. Enforcing on the domain alone protects the domain; it
* does not protect the ORGANIZATION, which is what the setting claims to do.
*
* So both are asked: the address's domain (which catches people who are not members yet) and every
* organization the user actually belongs to.
*/
function ssoOnlyForUser(user) {
if (!user) return null;
const byDomain = ssoOnlyForEmail(user.email);
if (byDomain) return byDomain;
const conn = db();
if (!conn) return null;
try {
/*
* WORKSPACE membership, not just organization_members.
*
* Almost nobody is in `organization_members`: only three places write it (creating an org,
* an org-SSO login, a platform admin creating an org) and nothing ever deletes a row. Every
* INVITED user, every admin-created account and every workspace assignment lands in
* `workspace_members` and nowhere else so an earlier version of this check covered org
* owners and people who had already used SSO, which is exactly the set the domain check
* already caught. A review invited an outside address into an SSO-only tenant and kept
* password login, then used it to invite more.
*/
return conn.prepare(`
SELECT o.id AS organization_id, o.name AS organization_name
FROM organizations o
WHERE o.sso_only = 1
AND (EXISTS (SELECT 1 FROM organization_members m WHERE m.organization_id = o.id AND m.user_id = ?)
OR EXISTS (SELECT 1 FROM workspace_members wm
JOIN workspaces w ON w.id = wm.workspace_id
WHERE w.organization_id = o.id AND wm.user_id = ?))
LIMIT 1
`).get(user.id, user.id) || null;
} catch (e) {
if (/no such table: (organization_members|organizations|workspace_members|workspaces)/i.test(e.message)) return null;
throw e; // drift on a table that exists — fail closed; the caller refuses the login
}
}
module.exports = {
list, get, publicList, getOrgProvider, ownerOf, forEmail,
ssoOnlyForEmail, ssoOnlyForUser, emailIsVerified, DEFAULT_SCOPES, SLUG_RE,
};

332
server/lib/oidc.js Normal file
View file

@ -0,0 +1,332 @@
'use strict';
/*
* OpenID Connect discovery, key handling and ID-token verification.
*
* This exists because the previous "OAuth" support verified nothing that mattered. The Google path
* asked Google's tokeninfo endpoint whether an ACCESS token was valid and then trusted the email in
* the reply; the Microsoft path handed a bearer token to Graph /me and trusted that. Neither ever
* checked WHO THE TOKEN WAS ISSUED FOR, and an access token is not a proof of identity it is a
* bearer credential for some resource, minted for some application, and Graph will happily describe
* the user behind a token issued to somebody else's app. Any site a user signs into that asks for
* `email` or `User.Read` could replay that token here and be issued a session as that user.
*
* So identity now comes from an ID TOKEN and nothing else, and the token has to survive:
*
* signature against the provider's published JWKS, restricted to asymmetric algorithms
* iss exactly the issuer discovery advertised
* aud contains our client_id (and azp === client_id when the token carries one)
* exp/nbf inside a small clock skew
* nonce equal to the one WE generated for this login, which is what stops a token
* obtained elsewhere even a correctly-audienced one being replayed here
*
* Deliberately dependency-free beyond `jsonwebtoken`: Node can import a JWK straight into a
* KeyObject, so there is no need for jwks-rsa and no second opinion about what a key is.
*/
const crypto = require('crypto');
const net = require('net');
const jwt = require('jsonwebtoken');
/*
* `alg: "none"` is the oldest JWT attack there is, and HMAC is nearly as bad here: an HS256 token is
* verified with a SHARED SECRET, and the only "key" we have for a provider is its PUBLIC one which
* an attacker also has, and could sign with. Only asymmetric families are ever acceptable.
*/
const ALLOWED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512'];
// Providers rotate keys and publish new ones ahead of use, so a short cache is safe and a miss is
// cheap. Discovery changes far less often but is cached the same way for one reason: a provider
// outage should not be able to stall every login for as long as it lasts.
const DISCOVERY_TTL_MS = 60 * 60 * 1000; // 1 hour
const JWKS_TTL_MS = 10 * 60 * 1000; // 10 minutes
const FETCH_TIMEOUT_MS = 8000;
const discoveryCache = new Map(); // issuer -> { at, doc }
const jwksCache = new Map(); // jwks_uri -> { at, keys }
/*
* Every URL this module fetches is ultimately chosen by whoever configured the provider and since
* per-org SSO, that is a CUSTOMER, not the operator. Discovery, JWKS and the token endpoint are
* therefore server-side request forgery primitives unless they are constrained.
*
* Two rules, both cheap:
* https only an http:// target is a plaintext credential leak as well as a way to reach
* services that never expected a request from inside the network.
* public hosts only loopback, RFC1918, CGNAT, link-local (169.254.169.254 is cloud metadata),
* multicast and reserved ranges, in BOTH address families, including the
* IPv4-mapped IPv6 forms that a prefix match misses.
*
* This is a literal-address check, not full SSRF protection: a hostname that RESOLVES to a
* private address still passes, because refusing that needs resolve-then-pin plumbing that Node's
* fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS".
* README.md documents this limitation under per-organization SSO.
*/
/*
* Addresses are parsed as ADDRESSES and compared by range. This started life as a prefix regex,
* which was wrong in both directions: it missed `[::ffff:127.0.0.1]` the entire IPv4 space
* re-encoded, which WHATWG URL normalises to `[::ffff:7f00:1]` so no dotted-quad prefix can match,
* and a review reached a loopback service straight through it while also matching plain TEXT, so
* every hostname beginning "fc" or "fd" was refused (fcm.googleapis.com, fcps.edu).
*/
const BLOCKED_V4 = [
['0.0.0.0', 8], // "this network"
['10.0.0.0', 8], // RFC1918
['100.64.0.0', 10], // CGNAT / Tailscale
['127.0.0.0', 8], // loopback
['169.254.0.0', 16], // link-local — 169.254.169.254 is cloud metadata
['172.16.0.0', 12], // RFC1918
['192.0.0.0', 24], // IETF protocol assignments
['192.168.0.0', 16], // RFC1918
['198.18.0.0', 15], // benchmarking
['224.0.0.0', 4], // multicast
['240.0.0.0', 4], // reserved
];
const v4ToInt = (ip) => ip.split('.').reduce((acc, o) => (acc * 256) + Number(o), 0);
function isBlockedV4(ip) {
const addr = v4ToInt(ip);
return BLOCKED_V4.some(([base, bits]) => {
const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0;
return (addr & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0;
});
}
function isBlockedV6(ip) {
const low = ip.toLowerCase();
// An IPv4-mapped or IPv4-compatible address is an IPv4 address wearing a hat — judge the IPv4.
const mapped = low.match(/^::(ffff:)?(\d+\.\d+\.\d+\.\d+)$/)
|| low.match(/^::(ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mapped) {
if (mapped[2] && mapped[2].includes('.')) return isBlockedV4(mapped[2]);
const hi = parseInt(mapped[2], 16), lo = parseInt(mapped[3], 16);
return isBlockedV4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.'));
}
if (low === '::' || low === '::1') return true; // unspecified (= loopback on Linux), loopback
if (/^f[cd]/.test(low)) return true; // fc00::/7 unique-local
if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local
if (/^ff/.test(low)) return true; // multicast
return false;
}
function assertFetchable(url) {
let u;
try { u = new URL(url); } catch { throw new Error(`not a URL: ${url}`); }
if (u.protocol !== 'https:') throw new Error('provider URLs must use https');
/*
* A trailing root dot is a legal, fully-qualified spelling of the same name, and WHATWG URL keeps
* it so `https://localhost./` matched neither alternative below and was ALLOWED. The parser
* normalises the literal-IP forms itself (`127.0.0.1.` becomes `127.0.0.1`), so only the name
* form slipped, and on a resolver that synthesizes `localhost.` it resolves to loopback.
*/
const host = u.hostname.replace(/\.$/, '');
// URL keeps IPv6 literals in brackets; net.isIP does not want them.
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
const family = net.isIP(bare);
const blocked = family === 4 ? isBlockedV4(bare)
: family === 6 ? isBlockedV6(bare)
: /^(localhost|.*\.localhost)$/i.test(host);
if (blocked) throw new Error('provider host is not publicly routable');
return u;
}
/** fetch with a timeout, because a hanging IdP must not hang a login forever. */
async function getJson(url) {
assertFetchable(url);
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
try {
/*
* redirect: 'manual' following redirects would let an allowlisted host bounce us to a blocked
* one, which defeats the check above entirely. A provider that redirects its own well-known
* document is misconfigured, and saying so is more useful than quietly following it.
*/
const res = await fetch(url, { signal: ctl.signal, redirect: 'manual' });
if (res.status >= 300 && res.status < 400) throw new Error(`${url} redirected; provider URLs must be final`);
if (!res.ok) throw new Error(`${url} responded ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
/**
* The provider's own description of itself.
*
* The discovered `issuer` is checked against the configured one. Discovery is fetched over TLS
* from a URL derived from the issuer, so this is belt-and-braces but a provider whose document
* claims a DIFFERENT issuer is either misconfigured or hostile, and either way its tokens must not
* be accepted under a name it does not own.
*/
async function discover(issuer) {
const key = String(issuer).replace(/\/+$/, '');
const hit = discoveryCache.get(key);
if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.doc;
const url = `${key}/.well-known/openid-configuration`;
const doc = await getJson(url);
const advertised = String(doc.issuer || '').replace(/\/+$/, '');
if (advertised !== key) {
throw new Error(`discovery issuer mismatch: configured ${key}, document says ${doc.issuer}`);
}
for (const required of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
if (!doc[required]) throw new Error(`discovery for ${key} is missing ${required}`);
}
discoveryCache.set(key, { at: Date.now(), doc });
return doc;
}
/**
* The signing key for one token.
*
* An unknown `kid` forces ONE refresh: that is the normal shape of a key rotation, and refusing to
* refetch would fail every login until the cache expired. It is bounded to one refresh per call so
* a token quoting nonsense cannot be used to hammer the provider.
*/
async function keyForKid(jwksUri, kid) {
let entry = jwksCache.get(jwksUri);
const fresh = entry && Date.now() - entry.at < JWKS_TTL_MS;
if (!fresh || !entry.keys.some((k) => k.kid === kid)) {
const doc = await getJson(jwksUri);
entry = { at: Date.now(), keys: Array.isArray(doc.keys) ? doc.keys : [] };
jwksCache.set(jwksUri, entry);
}
const jwk = entry.keys.find((k) => k.kid === kid)
// A provider with exactly one key may omit kid entirely; anything ambiguous is refused rather
// than guessed, because "try each key until one verifies" is how you accept a key you did not mean to.
|| (!kid && entry.keys.length === 1 ? entry.keys[0] : null);
if (!jwk) throw new Error(`no signing key for kid ${kid || '(none)'}`);
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
}
/**
* Verify an ID token and return its claims.
*
* `nonce` is REQUIRED by this function even though the spec makes it conditional. Every flow here
* is a browser login we initiated, so we always have one to compare and it is the single check
* that distinguishes "a token minted for us, now" from "a token minted for us at some point,
* captured, and replayed".
*/
async function verifyIdToken(idToken, { issuer, clientId, nonce }) {
if (!idToken || typeof idToken !== 'string') throw new Error('no id_token');
if (!nonce) throw new Error('no nonce to verify against');
const decoded = jwt.decode(idToken, { complete: true });
if (!decoded || !decoded.header) throw new Error('id_token is not a JWT');
if (!ALLOWED_ALGS.includes(decoded.header.alg)) {
throw new Error(`refusing id_token algorithm ${decoded.header.alg}`);
}
const doc = await discover(issuer);
const key = await keyForKid(doc.jwks_uri, decoded.header.kid);
// jsonwebtoken checks signature, exp, nbf, iss and aud. The algorithm allowlist is passed
// explicitly so the header cannot choose how it is verified.
const claims = jwt.verify(idToken, key, {
algorithms: ALLOWED_ALGS,
issuer: doc.issuer,
audience: clientId,
clockTolerance: 60,
});
if (claims.nonce !== nonce) throw new Error('id_token nonce does not match this login');
/*
* azp names the party the token was issued TO when it differs from the audience. If it is present
* it must be us: a token with our client_id merely in a multi-valued `aud`, issued to a different
* application, is exactly the confused-deputy case this whole file exists to prevent.
*/
if (claims.azp && claims.azp !== clientId) {
throw new Error('id_token was issued to a different application');
}
if (!claims.sub) throw new Error('id_token has no subject');
return claims;
}
/** PKCE S256. The verifier never leaves us; only its hash goes to the provider. */
function createPkce() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge, method: 'S256' };
}
const randomToken = () => crypto.randomBytes(32).toString('base64url');
/**
* Exchange the authorization code.
*
* PKCE means a public client needs no secret, which is what lets a self-hoster configure a provider
* without one. A secret is still sent when configured, because some providers (and some admins)
* require confidential clients.
*/
async function exchangeCode({ issuer, clientId, clientSecret, code, redirectUri, verifier }) {
const doc = await discover(issuer);
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: verifier,
});
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' };
if (clientSecret) {
// client_secret_basic is the form every provider accepts; client_secret_post is not universal.
headers.Authorization = 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64');
}
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
let payload;
try {
assertFetchable(doc.token_endpoint);
const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal, redirect: 'manual' });
payload = await res.json().catch(() => ({}));
if (!res.ok) {
// The provider's own error is far more useful than "exchange failed" — a wrong redirect_uri
// or an unregistered client is the overwhelmingly common setup mistake and it says so here.
throw new Error(payload.error_description || payload.error || `token endpoint responded ${res.status}`);
}
} finally {
clearTimeout(timer);
}
if (!payload.id_token) throw new Error('provider returned no id_token — is the openid scope requested?');
return payload;
}
/** Test seam: drop cached discovery/JWKS so a test can change what a provider claims. */
function _resetCaches() {
discoveryCache.clear();
jwksCache.clear();
}
/**
* The provider's published keys, straight from the document. Used by the configuration test so an
* admin learns at setup time that a provider publishes no signing keys, rather than at first login.
*/
async function fetchJwks(jwksUri) {
return getJson(jwksUri);
}
module.exports = {
discover,
assertFetchable,
fetchJwks,
verifyIdToken,
exchangeCode,
createPkce,
randomToken,
ALLOWED_ALGS,
_resetCaches,
};

View file

@ -1,7 +1,8 @@
'use strict';
/*
* The CSS needed to rotate a full-screen player container.
* The CSS needed to rotate a full-screen player container, and (below) the CSS needed to show that
* rotated output back to a human in the dashboard.
*
* This looks trivial and is not, because rotating a box does NOT move it. The web player set
* `width:100vh; height:100vw` and `rotate(90deg)` on a container pinned `inset: 0`, which leaves
@ -63,9 +64,72 @@ function orientationStyle(orientation) {
};
}
/** Does this orientation put the panel's long edge vertical? 90 and 270 swap the axes; 0/180 don't. */
function swapsAxes(orientation) {
const deg = ROTATION_DEG[orientation];
return deg === 90 || deg === 270;
}
/**
* The CSS needed to show a rotated display's OUTPUT its framebuffer inside a fixed dashboard
* box, as a person standing in front of the panel sees it.
*
* #238: the dashboard preview of a 90/270 device was sideways while the panel was right, and the
* reason is that the dashboard only did half the job. A portrait panel is a landscape framebuffer
* that the player rotates content INSIDE (+90), hung on the wall turned the other way (-90); the
* two cancel and the viewer sees upright portrait. The dashboard iframed the player into a box it
* had already made portrait-shaped, so the player rotated content a second time inside a box that
* was pretending to be the finished picture one rotation applied, the mount's never modelled.
* Designers checking their work on a portrait screen saw sideways content and could not tell a
* real fault from a preview artefact, so every portrait anomaly became a support question.
*
* So the frame stands in for the physical mount and rotates by the INVERSE of the player's angle.
* Rotating it the SAME way instead is the tempting mistake and the worst kind of wrong: 90+90
* lands upside-down, which reads as "nearly right" and gets shipped.
*
* The dimension swap matters as much as the angle. Composing into a box the shape of the real
* FRAMEBUFFER (stage axes swapped) and turning that is not a no-op round trip it is what makes
* the player lay the content out in the same portrait box the panel uses. Feed the player a
* portrait-shaped viewport instead and every zone, aspect and object-fit decision is computed for
* the wrong canvas.
*
* @param {string} orientation landscape | portrait | landscape-flipped | portrait-flipped
* @param {{width:number,height:number}} box the on-screen stage, in px, AS THE VIEWER SEES IT
* @returns {{transform:string,width:string,height:string,top:string,left:string,transformOrigin:string}}
* Values to assign directly onto element.style. Empty string means "clear it" the
* landscape state must clear every property the rotated state sets, or a device switched
* back to landscape keeps a stale swapped size and looks broken until a reload.
*/
function previewFrameStyle(orientation, box) {
const deg = ROTATION_DEG[orientation];
const w = box && box.width, h = box && box.height;
// Unknown/landscape, or a stage that has not been laid out yet (a hidden tab measures 0x0):
// clear back to the base CSS rather than pinning a 0px frame nobody can see.
if (!deg || !(w > 0) || !(h > 0)) {
return { transform: '', width: '', height: '', top: '', left: '', transformOrigin: '' };
}
const swap = swapsAxes(orientation);
return {
transform: 'translate(-50%, -50%) rotate(' + ((360 - deg) % 360) + 'deg)',
width: (swap ? h : w) + 'px',
height: (swap ? w : h) + 'px',
top: '50%',
left: '50%',
transformOrigin: 'center center',
};
}
/** Stage aspect for a device, as the viewer sees it: '9 / 16' for a portrait-hung 16:9 panel. */
function previewAspectRatio(orientation, panelW, panelH) {
const w = panelW || 16, h = panelH || 9;
return swapsAxes(orientation) ? (h + ' / ' + w) : (w + ' / ' + h);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { orientationStyle, ROTATION_DEG };
module.exports = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG };
}
if (typeof window !== 'undefined') {
window.OrientationStyle = { orientationStyle, ROTATION_DEG };
window.OrientationStyle = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG };
}

View file

@ -41,6 +41,8 @@ const { rollingCounter, bump, read } = require('./rolling-counter');
const rateBackoffCtr = rollingCounter();
// --- minimal semver-ish parse/compare (no dependency) ---
const { preCmp } = require('./version-precedence');
function parseVer(v) {
if (typeof v !== 'string') return null;
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
@ -54,8 +56,10 @@ function cmpParsed(a, b) {
if (a.pre === b.pre) return 0;
if (a.pre === null) return 1; // release outranks a prerelease of the same core
if (b.pre === null) return -1;
// lexical prerelease compare — fine for beta1..beta9 (cores decide everything else).
return a.pre < b.pre ? -1 : (a.pre > b.pre ? 1 : 0);
// Natural prerelease compare: digit runs numerically, so alpha8 < alpha9 < alpha10 < alpha11.
// A plain lexical compare (what this used to do) put every build from alpha10 onward BELOW
// alpha8, so the check answered client-newer and the fleet could not be moved forward at all.
return preCmp(a.pre, b.pre);
}
function cmp(a, b) { const pa = parseVer(a), pb = parseVer(b); return (!pa || !pb) ? null : cmpParsed(pa, pb); }

View file

@ -56,59 +56,168 @@ const CAP_SET = new Set(CAPABILITIES);
/*
* Baselines for displays that declare nothing.
*
* Only things that have always worked on that platform. Anything conditional screenshots that
* need accessibility, kiosk that needs device owner, native sync that needs one L2 network is
* omitted, so a legacy display shows those controls only once it declares them. Better a control
* that appears late than one that lies today.
* THE RULE, and it is the only one that keeps this table honest: a baseline entry describes what
* the LAST RELEASED player for that platform does, unconditionally, with no privilege it might not
* have been granted. Not what HEAD does HEAD declares for itself. Not what the platform could do
* a capability nobody shipped is a button nobody can press.
*
* Every entry below was checked against `git show v1.9.28:<player source>`, the last release before
* capability declaration existed at all, because v1.9.29 is the first build in which any player
* declares anything. Every display that falls back to a baseline is therefore running v1.9.28 or
* older by construction, and that is the build the justifications cite.
*/
const BASELINE = {
android: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
// set_volume and set_brightness are #160 Track-A, released in v1.9.10 — long before anything
// still in the field. Both are Tier 0: MainActivity applies them with no owner, no admin and
// no WRITE_SETTINGS, so they are unconditional on any build a fielded panel could be running.
'audio.mute', 'audio.volume',
'display.rotation', 'display.power', 'display.brightness',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.reboot', 'system.restart_player', 'system.self_update',
// Capture without accessibility falls back to ScreenshotCapture.captureView, which is a real
// frame of the player's own view — i.e. of the content. Narrower than the full-screen path,
// but the operator gets a picture, not a dead button.
'remote.screenshot', 'remote.stream',
'remote.input',
'system.restart_player', 'system.self_update',
'sync.clock', 'offline.cache',
// display.power is KEPT for the un-updated Android fleet, deliberately, with the trade-off
// recorded here because it is genuinely two-sided.
//
// v1.9.28 MainActivity answers screen_on with
// Log.w("screen_on: no privileged wake path on a non-rooted panel — no-op")
// so the ON half is dead on every fielded Android panel, while screen_off does work (device
// owner / device-admin FORCE_LOCK, else the accessibility lock). One capability renders BOTH
// dashboard buttons, so this baseline cannot offer the working half without the dead one.
//
// Withholding it takes away blank-at-night, which is the half signage actually schedules, from
// every panel that has not updated. Keeping it means an operator can sleep a screen and not
// wake it from the dashboard — mitigated by the fact that a schedule, a restart, or anyone
// standing at the panel will wake it, while nothing else can blank it.
//
// A panel that HAS updated declares for itself, and PlayerCapabilities.kt gates its own claim
// on both halves — so this governs the un-updated fleet only. If the dead ON button turns out
// to be the louder complaint, split it into display.power_off / display.power_on rather than
// dropping the pair.
//
// NOT system.reboot. STPolicy.reboot() requires device owner; off-owner v1.9.28 falls back to
// the accessibility power DIALOG, which needs a human standing at the screen — and on the
// accessibility-enabled panels that are common in this fleet it paints that dialog OVER the
// signage. Device-owner provisioning is not released (#161/PR #168 is still open), so the set
// of panels that are both device owner AND pre-1.9.29 is effectively empty.
// ⚠️ Consequence, deliberately accepted: services/scheduler.js gates the nightly scheduled
// reboot on this capability, so scheduled reboots now no-op for undeclared Android panels
// instead of logging "scheduled reboot fired" for a panel that never rebooted. That log line
// is the reason the gate is there; the honest answer is to skip, not to claim.
//
// NOT system.shell / system.kiosk / system.time / system.install_apk / system.brightness /
// system.screen_timeout: every one is device-owner or WRITE_SETTINGS conditional.
],
tizen: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
// audio.mute only. A FIELDED Tizen panel has no set_volume handler at all — the command falls
// through to "unknown command", so the dashboard slider does nothing. Updated panels declare
// audio.volume for themselves once they ship a handler; the baseline describes what an
// un-updated one can actually do, which is the whole reason it exists.
// audio.mute only. `git show v1.9.28:tizen/js/app.js` has NO set_volume handler — the command
// falls through STDeviceControl.run to "unknown command", so the dashboard slider does nothing
// on every fielded panel. (HEAD ships applyVolume, but see the note on BASELINE.web: it reads
// payload.value while the dashboard sends payload.level, so even HEAD's slider is dead. The
// baseline stays out until a released .wgt honours the payload the product actually sends.)
'audio.mute',
'display.rotation',
// Both really are implemented in the shipped player (captureAndSend / startStreaming), so
// omitting them would have hidden working controls on every legacy Tizen panel.
'remote.screenshot', 'remote.stream',
'remote.input',
// ADDED after audit. v1.9.28 app.js implements BOTH halves with no partner signing and no
// panel API: screen_off -> showScreenOff() paints the blanking overlay, screen_on ->
// clearScreenOff() + keepAwake(). Unlike Android above, neither half is privilege-gated, so
// the pair is honest. The panel backlight stays lit — the log line says which mechanism ran —
// but the screen genuinely goes dark, and HEAD's capabilities.js declares it for that reason.
'display.power',
'system.restart_player',
'sync.clock',
// NOT offline.cache: Tizen caches only the playlist JSON (st_payload_cache in localStorage).
// There is no service worker and no media cache, so the bytes still come from the network and
// content does NOT survive an outage. My first baseline claimed it — caught by the platform
// audit, and exactly the kind of optimistic claim this model exists to stop.
// NOT offline.cache: v1.9.28 has no tizen/js/media-cache.js at all (the file is new at HEAD).
// The fielded player caches only the playlist JSON (st_payload_cache in localStorage), so an
// outage leaves the panel knowing exactly what it cannot show. My first baseline claimed it —
// caught by the platform audit, and exactly the kind of optimistic claim this model exists to
// stop.
],
/*
* A BrightSign that declares nothing is a BrightSign we cannot prove has a host bridge, and that
* is the whole story of this baseline.
*
* The JS half of the bridge is served BY US (server.js routes /player/st-bridge.js at
* brightsign/st-bridge.js), so it is always current but it is only half. `port` exists only
* inside an roHtmlWidget created with nodejs_enabled:true, which is the on-device BrightScript's
* decision, and `git ls-tree v1.9.28 brightsign/` shows no st-bridge.js at all: no released
* package ever shipped the two halves as a pair. The one real BrightSign we have runs BSN
* Supervisor's widget rather than our autorun.brs, and BS.hasHost() is false on it.
*
* A unit that DOES have a bridge declares for itself and never reads this list the page
* computes hasHost() at registration. So this baseline only ever answers for a row that has not
* re-registered, and the right answer for a display we know nothing about is the floor:
* everything below is "the web player with no bridge", and nothing above that.
*/
brightsign: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
'audio.mute', 'audio.volume',
'display.rotation', 'display.power',
'audio.mute',
// CSS transform. Graphics rotate; with hwz the video sits on a hardware plane that ignores it,
// so this is partial — but nothing routes a COMMAND to display.rotation and no control is
// gated on it, so the entry describes content rendering rather than offering a button.
'display.rotation',
'remote.input',
'system.reboot', 'system.restart_player',
'sync.clock', 'offline.cache',
// RESTORED in 1.9.31 with BASELINE.web, and for the same reason plus one of its own: a
// BrightSign runs the web player we serve, so it gets the fixed handler the moment the server
// does. The unit-specific question is whether the media element is even reachable on a player
// that puts video on a hardware plane — and that question is already settled by `audio.mute`
// above, which this baseline has always claimed: set_volume reaches setMediaVolume() and
// device:mute-changed reaches `currentVideoEl.muted`, the same element by the same path. If hwz
// silently swallowed one it would swallow both, so volume is exactly as honest as mute here.
'audio.volume',
'sync.clock',
// NOT offline.cache. This is the documented case, not a hypothetical: the XT245 on alpha has
// navigator.serviceWorker, passes every presence check, and then never fetches sw.js because
// its widget refuses the registration. It advertised offline caching to the fleet and could
// not cache one byte. A widget with no storage_path has no persistent storage at all, and the
// baseline cannot know which kind of widget it is talking to.
//
// NOT system.restart_player. `refresh` reaches restartPlayer(), which without a host does
// location.reload() — and a page-initiated reload does not reliably bring an roHtmlWidget
// back. That is what darkened a customer's panel on 2026-07-28. st-bridge.js withholds this
// for the same reason; a baseline that hands it to every undeclared unit undoes that.
//
// NOT system.reboot / display.power / display.resolution / system.self_update: all four are
// BrightScript calls through a bridge this unit is not known to have.
//
// NOT remote.screenshot / remote.stream: a canvas capture on a hwz player cannot read the video
// plane, so it returns a frame with a hole where the content is. (audio.volume moved INTO the
// list above in 1.9.31 — the payload it was waiting on now lands.)
],
// A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or
// capture anything outside its own document.
web: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
'audio.mute', 'audio.volume',
'audio.mute',
'display.rotation',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.restart_player',
// RESTORED in 1.9.31, having been removed by the audit that found the slider dead. Both of the
// reasons it was removed have expired, and the second one was reasoning from the wrong artifact:
// 1. It read `data.payload?.value ?? data.value` while the dashboard sends `{ level: 0..1 }`,
// so the number was undefined and the handler declined. Fixed in 1.9.31 — index.html now
// takes the fraction as canonical (volumeLevelFromCommand) and set_volume reaches
// setMediaVolume().
// 2. The removal cited `git show v1.9.28:server/player/index.html` having no handler at all.
// But this player is SERVED BY THE SERVER: a browser panel loads it from whatever build is
// running, not from the release its row was created under. There is no such thing as a
// browser panel stuck on the v1.9.28 player once the server moves — which is the whole
// difference between this baseline and the Android/Tizen ones below, where an un-updated
// panel really is running an old artifact.
// So the moment the server ships the fix, an undeclared web display can be driven, and holding
// the entry back would hide a control that works. Released and live on prod 2026-08-06.
'audio.volume',
'sync.clock', 'offline.cache',
],
};
@ -120,8 +229,14 @@ const BASELINE = {
function platformFamily(device) {
const platform = String((device && device.platform) || '').toLowerCase();
const android = String((device && device.android_version) || '');
const clientType = (device && device.client_type) || '';
if (platform.includes('brightsign')) return 'brightsign';
if (platform.includes('tizen')) return 'tizen';
// Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt' (see
// tizen/js/app.js). `platform` is the primary key, but it lives in a column that a register from
// a client not sending it used to overwrite — and misreading a Tizen panel as a browser tab
// hands it a volume slider with no handler behind it. Two signals, one conclusion.
if (clientType === 'wgt') return 'tizen';
// client_type 'apk' is the Android player; android_version that is NOT the web player's
// "Web/..." shape is the older signal for the same thing.
if ((device && device.client_type === 'apk') || (android && !android.startsWith('Web/'))) return 'android';
@ -182,6 +297,11 @@ function parseDeclared(raw) {
*
* A command mapped to null needs no capability: it is a diagnostic every player understands, and
* refusing it would remove the tool you use to work out why a panel is misbehaving.
*
* A command may map to a LIST, meaning any one of them is enough. That is not a convenience: it is
* how a capability name that no shipped player declares stays in the vocabulary without taking its
* commands down with it. The first name in the list is the canonical one and is what a refusal
* reports, so the operator is told what the panel is missing in the vocabulary they see elsewhere.
*/
const COMMAND_CAPABILITY = {
// lifecycle
@ -192,6 +312,9 @@ const COMMAND_CAPABILITY = {
launch: 'system.restart_player',
refresh: 'system.restart_player',
update: 'system.self_update',
// Clearing the staged-APK cache is part of the same self-update surface: a player that can
// update itself is a player that can hold a bad download and needs a way to drop it.
clear_update_cache: 'system.self_update',
// display
screen_on: 'display.power',
@ -208,18 +331,49 @@ const COMMAND_CAPABILITY = {
// device-owner surface (#161 Tier-2)
kiosk_lock: 'system.kiosk',
kiosk_unlock: 'system.kiosk',
lock_now: 'system.device_owner',
power_menu: 'system.device_owner',
status_bar: 'system.device_owner',
block_uninstall: 'system.device_owner',
unblock_uninstall: 'system.device_owner',
/*
* These five were UNREACHABLE for the entire fleet until this audit, and nothing failed
* loudly enough to notice.
*
* 'system.device_owner' is declared by NO player. It is not in PlayerCapabilities.kt, not in
* tizen/js/capabilities.js, not in the web player's declaredCapabilities(), not in st-bridge.js,
* and not in any baseline. So `supports()` returned false for every device on every platform,
* and every one of these commands was refused including on the device-owner panels the whole
* #161 Tier-2 surface was built for. The dashboard still rendered the buttons, because
* device-detail.js gates that block on `device.tier === 2 ||` as well, so an operator on a real
* owner panel pressed "Lock now" and got a silent server-side refusal.
*
* Until a player declares 'system.device_owner' for itself, 'system.kiosk' stands in, and it is
* an exact stand-in rather than a loose one: PlayerCapabilities.kt declares system.kiosk under
* `if (isOwner)` and nothing else, which is precisely the condition under which STPolicy's
* owned() actions setStatusBarDisabled, setUninstallBlocked, lockNow, reboot do anything.
* No non-Android player declares system.kiosk; Tizen and BrightSign both refuse it explicitly
* and in writing, so this cannot leak the commands onto a platform that would swallow them.
*
* The canonical name stays first so a refusal still says 'system.device_owner'.
*/
lock_now: ['system.device_owner', 'system.kiosk'],
power_menu: ['system.device_owner', 'system.kiosk'],
status_bar: ['system.device_owner', 'system.kiosk'],
block_uninstall: ['system.device_owner', 'system.kiosk'],
unblock_uninstall: ['system.device_owner', 'system.kiosk'],
set_time: 'system.time',
set_timezone: 'system.time',
shell: 'system.shell',
install_apk: 'system.install_apk',
// remote view
enable_system_capture: 'remote.screenshot',
/*
* Remote view. Ungated, and the reason is a circle: enable_system_capture asks Android to raise
* the MediaProjection consent dialog, which is how a panel GAINS full-screen capture. Gating it
* on 'remote.screenshot' meant the only panel that needs it one with neither accessibility nor
* a projection grant, which therefore declares no remote.screenshot was the one panel that
* could not be sent it. A bootstrap cannot require the thing it bootstraps.
*
* The dashboard still has the other half of this bug: device-detail.js renders the "enable
* system view" button behind `can('remote.screenshot')`. Fixing that is a frontend change and is
* written up in docs/player-parity.md; ungating the command is the half that lives here.
*/
enable_system_capture: null,
// Diagnostics: deliberately unrestricted. set_debug turns on the log stream you need precisely
// when a panel is behaving in a way its capability declaration did not predict.
@ -227,13 +381,29 @@ const COMMAND_CAPABILITY = {
};
/**
* The capability a command requires, or null when it needs none.
* Unknown commands also return null this map gates, it does not authorise: the allow-list of
* Every capability that would satisfy `type`, as an array. Empty means the command is ungated.
* Unknown commands are ungated too this map gates, it does not authorise: the allow-list of
* valid command names lives with the routes, and duplicating it here would mean a new command
* silently stops working until someone remembers to add it in two places.
*
* @param {string} type
* @returns {string[]}
*/
function capabilitiesForCommand(type) {
if (!Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type)) return [];
const value = COMMAND_CAPABILITY[type];
if (value === null || value === undefined) return [];
return Array.isArray(value) ? value.slice() : [value];
}
/**
* The CANONICAL capability a command requires, or null when it needs none.
* Kept returning a single string because that is what a refusal reports and what the dashboard
* puts in front of an operator: "needs system.device_owner" is an answer, an array is a puzzle.
*/
function capabilityForCommand(type) {
return Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type) ? COMMAND_CAPABILITY[type] : null;
const list = capabilitiesForCommand(type);
return list.length ? list[0] : null;
}
/**
@ -241,13 +411,13 @@ function capabilityForCommand(type) {
* @returns {{ok: true} | {ok: false, capability: string}}
*/
function commandAllowed(device, type) {
const cap = capabilityForCommand(type);
if (!cap) return { ok: true };
if (supports(device, cap)) return { ok: true };
return { ok: false, capability: cap };
const needed = capabilitiesForCommand(type);
if (!needed.length) return { ok: true };
if (needed.some((cap) => supports(device, cap))) return { ok: true };
return { ok: false, capability: needed[0] };
}
module.exports = {
CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared,
COMMAND_CAPABILITY, capabilityForCommand, commandAllowed,
COMMAND_CAPABILITY, capabilityForCommand, capabilitiesForCommand, commandAllowed,
};

View file

@ -13,8 +13,11 @@
//
// Dependency-free UMD: Node (require) + browser/Tizen (window.PlayerMediaHealth).
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.PlayerMediaHealth = factory();
// BOTH, not either/or — see schedule-eval.js. Node integration in a BrightSign widget made the
// browser branch unreachable, so the player ran without its media-health decision there.
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.PlayerMediaHealth = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';

View file

@ -0,0 +1,190 @@
'use strict';
/*
* Make sure the dependencies this build needs are actually installed and loadable BEFORE anything
* requires them.
*
* The normal upgrade path (scripts/upgrade.sh) runs `npm ci --omit=dev`, so this is not for the
* happy case. It is for the three ways a running box ends up with the wrong node_modules:
*
* ROLLBACK checking out an older tag to back out a bad release restores that tag's
* package.json but not its packages, so the server dies on a MODULE_NOT_FOUND for
* something the newer build had removed. That is a bad moment to be reading a
* stack trace: you are already rolling back because something else broke.
* NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes
* every boot fail with NODE_MODULE_VERSION mismatch, which reads like database
* corruption and is not.
*
* better-sqlite3 is pinned to EXACTLY 12.9.0, not a caret range, and the reason
* is invisible from package.json: 12.10.0 DROPPED the prebuilt binary for Node 20
* (ABI 115) while still advertising `"node": "20.x || ..."` in engines. So a caret
* resolves to 12.11.x, finds no prebuild on Node 20, and silently falls through to
* `node-gyp rebuild` a from-source compile during install, and during the repair
* below. That matters here: this file rebuilds synchronously BEFORE the server
* listens, and prod's systemd unit has TimeoutStartSec=90 with Restart=always, so a
* slow or failing compile is a boot loop rather than a self-heal. 12.9.0 is the last
* version shipping prebuilds for BOTH Node 20 (115) and Node 22 (127), which is what
* lets the runtime move without the module having to compile at all.
* Re-check the release assets before widening the pin.
* HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install.
*
* All three present as a server that will not start, with an error that names a file rather than
* the action needed. Detecting and repairing is a few seconds; diagnosing is an outage.
*
* Deliberately dependency-free only Node builtins. Anything it required could be the very
* thing that is missing.
*
* Set ST_SKIP_DEP_PREFLIGHT=1 to turn it off (air-gapped hosts, or an operator who manages
* node_modules themselves and does not want a boot reaching for the network).
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const SERVER_DIR = path.join(__dirname, '..');
const NODE_MODULES = path.join(SERVER_DIR, 'node_modules');
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; // a cold install on a Pi is genuinely slow
/** Which declared dependencies are not on disk. */
function missingDeps() {
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(path.join(SERVER_DIR, 'package.json'), 'utf8'));
} catch {
return []; // no package.json is not our problem to diagnose
}
const declared = Object.keys(pkg.dependencies || {});
return declared.filter((name) => {
// A scoped or nested name is still one directory below node_modules.
try { return !fs.existsSync(path.join(NODE_MODULES, name, 'package.json')); } catch { return true; }
});
}
/**
* Is the native module loadable by THIS Node?
*
* Checked by actually loading it, because the failure is an ABI mismatch that no version string
* comparison catches reliably a rebuild against the same major can still differ.
*/
function nativeModuleBroken() {
try {
/*
* CONSTRUCT one, do not merely require it.
*
* better-sqlite3's entry point is plain JavaScript and loads the compiled `.node` binding
* lazily, so `require()` alone SUCCEEDS under a Node whose ABI the binary was not built for
* the first version of this check did exactly that and reported a broken install as healthy,
* verified against a real Node 18 / Node 20 mismatch. Opening an in-memory database is what
* actually pulls the binding in, and it touches no file.
*/
const Database = require('better-sqlite3');
new Database(':memory:').close();
return null;
} catch (e) {
const msg = String((e && e.message) || '');
if (/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|was compiled against a different/i.test(msg)) return msg;
if (/Cannot find module/i.test(msg)) return msg;
// Anything else is a real error in the module, not an installation problem — let it surface
// later with its own stack rather than being masked by an npm run.
return null;
}
}
function run(args, label) {
console.log(`[preflight] ${label}: npm ${args.join(' ')}`);
execFileSync('npm', args, { cwd: SERVER_DIR, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS });
}
function fail(reason, hint) {
console.error(`[preflight] ${reason}`);
console.error(`[preflight] ${hint}`);
console.error('[preflight] Set ST_SKIP_DEP_PREFLIGHT=1 to boot without this check.');
process.exit(1);
}
function preflight() {
// Same spellings as every other boolean the server accepts, so an operator who writes `true`
// does not silently get a boot that reaches for the registry anyway.
if (['1', 'true', 'yes'].includes(String(process.env.ST_SKIP_DEP_PREFLIGHT || '').toLowerCase())) return;
const missing = missingDeps();
const nodeModulesAbsent = !fs.existsSync(NODE_MODULES);
if (missing.length || nodeModulesAbsent) {
const what = nodeModulesAbsent
? 'node_modules is missing'
: `${missing.length} dependency/dependencies missing: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`;
console.warn(`[preflight] ${what} — installing before start.`);
try {
/*
* `npm ci` when there is a lockfile and nothing installed: it is reproducible and it is what
* upgrade.sh uses. Otherwise `npm install`, because `ci` DELETES node_modules first and would
* throw away a working tree to fix one missing package.
*/
const hasLock = fs.existsSync(path.join(SERVER_DIR, 'package-lock.json'));
if (hasLock && nodeModulesAbsent) {
/*
* Nothing installed, so `ci` has nothing to destroy and gives a reproducible tree.
*
* `--omit=dev` ONLY when this is plainly a production boot. Applying it unconditionally
* meant a cold start on a developer machine installed 307 packages and left `npm test`
* broken js-yaml, puppeteer-core and socket.io-client absent which is the same class of
* surprise as the prune this file already warns about, arriving through the other branch of
* the same `if`.
*/
const prod = process.env.NODE_ENV === 'production';
run(prod ? ['ci', '--omit=dev', '--no-audit', '--no-fund'] : ['ci', '--no-audit', '--no-fund'], 'installing');
} else {
/*
* Install ONLY what is missing, by name, and never `--omit=dev` on a populated tree.
*
* `npm install --omit=dev` reconciles the whole tree, which PRUNES devDependencies so
* merely starting the server deleted socket.io-client, puppeteer-core and js-yaml, and broke
* `npm test`. A review watched it happen. A boot-time repair that quietly removes packages
* is worse than the failure it fixes, so this touches nothing it was not asked to.
*
* `--no-save` because a server starting up has no business editing package.json.
*/
run(['install', '--no-save', '--no-audit', '--no-fund', ...missing], 'installing missing packages');
}
} catch (e) {
/*
* An install can fail because ANOTHER server started at the same moment and won the race
* observed as `ENOTEMPTY … rename node_modules/fs-extra`. The tree is complete by the time we
* see the error, so exiting here killed a process that had nothing wrong with it. Re-check
* before giving up; only a genuinely incomplete tree is fatal.
*/
const afterFailure = missingDeps();
if (afterFailure.length) {
fail(`could not install dependencies: ${e && e.message}`,
'Run `npm ci --omit=dev` in the server directory, or check network access to the npm registry.');
}
console.warn(`[preflight] install reported an error but the tree is complete (${e && e.message}) — continuing.`);
}
const still = missingDeps();
if (still.length) {
fail(`still missing after install: ${still.join(', ')}`, 'Check the npm output above.');
}
console.log('[preflight] dependencies installed.');
}
const nativeProblem = nativeModuleBroken();
if (nativeProblem) {
console.warn(`[preflight] better-sqlite3 will not load under Node ${process.version} — rebuilding.`);
console.warn(`[preflight] ${nativeProblem.split('\n')[0]}`);
try {
run(['rebuild', 'better-sqlite3'], 'rebuilding native module');
} catch (e) {
fail(`could not rebuild better-sqlite3: ${e && e.message}`,
`Run \`npm rebuild better-sqlite3\` in the server directory. This usually means Node changed version (now ${process.version}) and the module needs recompiling; a build toolchain (python3, make, g++) must be present.`);
}
if (nativeModuleBroken()) {
fail('better-sqlite3 still will not load after a rebuild.',
'Delete server/node_modules and run `npm ci --omit=dev`.');
}
console.log('[preflight] native module rebuilt.');
}
}
module.exports = { preflight, missingDeps, nativeModuleBroken };

View file

@ -0,0 +1,57 @@
'use strict';
/*
* Email domains nobody may claim for organization SSO.
*
* Per-org SSO routes everyone at a domain to that organization's identity provider. Applied to a
* company domain that is the point. Applied to a CONSUMER domain it is an attack: one tenant claims
* `gmail.com`, and from then on every Gmail user who types their address into this product's login
* page is offered a "sign in with your organization" button that sends them to infrastructure the
* tenant controls phishing launched from the vendor's own trusted login screen. First-claim-wins
* also lets one cheap account deny a public domain to everyone else, permanently.
*
* This is a floor, not a ceiling. It stops the mass-abuse case; it does NOT stop a tenant
* claiming a domain that belongs to some specific other company. Only proof of control a DNS TXT
* record, or a challenge to postmaster@ settles that, and until it exists a claimed domain means
* "nobody else had claimed it", not "they own it".
*
* Kept as data, in one file, because it is a list that will need adding to and that is the cheapest
* possible edit. Matching is exact on the registrable domain, so `mail.google.com` is not blocked by
* `gmail.com` subdomains of consumer providers are not a realistic sign-in domain anyway.
*/
const PUBLIC_EMAIL_DOMAINS = new Set([
// Google
'gmail.com', 'googlemail.com',
// Microsoft
'outlook.com', 'outlook.co.uk', 'hotmail.com', 'hotmail.co.uk', 'hotmail.fr', 'hotmail.it',
'live.com', 'live.co.uk', 'msn.com', 'passport.com',
// Yahoo and friends
'yahoo.com', 'yahoo.co.uk', 'yahoo.co.jp', 'yahoo.fr', 'yahoo.de', 'yahoo.ca', 'yahoo.com.au',
'ymail.com', 'rocketmail.com', 'aol.com', 'aim.com',
// Apple
'icloud.com', 'me.com', 'mac.com',
// Privacy-focused
'proton.me', 'protonmail.com', 'pm.me', 'tutanota.com', 'tutanota.de', 'tuta.io', 'tuta.com',
'duck.com', 'hey.com', 'fastmail.com', 'fastmail.fm',
// Other large consumer providers
'gmx.com', 'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'web.de', 'mail.com', 'email.com',
'zoho.com', 'zohomail.com', 'yandex.com', 'yandex.ru', 'ya.ru', 'mail.ru', 'bk.ru', 'inbox.ru',
'list.ru', 'rambler.ru',
'qq.com', 'foxmail.com', '163.com', '126.com', 'sina.com', 'sina.cn', 'naver.com', 'daum.net',
'hanmail.net', 'rediffmail.com',
// ISP-style mailboxes, where the domain belongs to the ISP and not to any customer
'comcast.net', 'verizon.net', 'att.net', 'sbcglobal.net', 'bellsouth.net', 'cox.net',
'charter.net', 'earthlink.net', 'juno.com', 'optonline.net', 'roadrunner.com',
'btinternet.com', 'sky.com', 'virginmedia.com', 'talktalk.net', 'orange.fr', 'wanadoo.fr',
'free.fr', 'laposte.net', 'libero.it', 'virgilio.it', 'tiscali.it', 'terra.com.br', 'uol.com.br',
'bol.com.br', 'telus.net', 'shaw.ca', 'rogers.com', 'sympatico.ca', 'bigpond.com', 'optusnet.com.au',
't-online.de', 'freenet.de', 'arcor.de',
]);
/** True when this domain is a consumer mailbox provider rather than an organization's own domain. */
function isPublicEmailDomain(domain) {
return PUBLIC_EMAIL_DOMAINS.has(String(domain || '').trim().toLowerCase());
}
module.exports = { PUBLIC_EMAIL_DOMAINS, isPublicEmailDomain };

View file

@ -22,8 +22,13 @@
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.ScheduleEval = factory();
// BOTH, not either/or: a BrightSign widget runs with Node integration, so `module` exists in
// page scope and an `else` left root.ScheduleEval undefined there. The player falls back to
// "always active" when it is missing — i.e. per-item DAYPARTING silently stopped applying on
// that platform, and scheduled content played outside its window with nothing in any log.
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.ScheduleEval = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';

200
server/lib/telemetry.js Normal file
View file

@ -0,0 +1,200 @@
'use strict';
/*
* Opt-in install statistics.
*
* WHY THIS EXISTS: there is no way to answer "how many screens run ScreenTinker?" the product is
* self-hostable by design, so most installs are invisible to us on purpose. This asks, once, and
* only reports if the operator says yes.
*
* WHAT IS SENT the whole payload, three fields:
*
* { instance_id, version, screen_count }
*
* and nothing else. No hostnames, no addresses, no organization or user names, no device names,
* no content or filenames, no user counts. The list is short on purpose: every field added costs
* participation, and participation is the only thing that makes the resulting number worth
* quoting. Anyone can verify it the payload is built in `payload()` below, in one place, and
* `getLastReport()` shows an operator the exact bytes last sent.
*
* `instance_id` is a random UUID generated on first use and kept in app_settings. It carries no
* information about the install; its only job is to let two reports from the same server be
* recognised as the same server, so a count is a count rather than a sum of duplicates. That does
* make a report PSEUDONYMOUS rather than anonymous, and the wording shown to operators says so.
*
* Restoring a backup or cloning a VM carries the id with it, so two installs report as one.
* Deliberate: under-counting is the honest failure here, and the alternative (re-identifying on
* some hardware signal) means collecting exactly the kind of thing this file promises not to.
*
* Opt-in populations are self-selected, so the total is a FLOOR "at least N screens" never
* a basis for extrapolating a fleet size.
*/
const crypto = require('crypto');
const appSettings = require('./app-settings');
const KEY_ID = 'telemetry_instance_id';
const KEY_ENABLED = 'telemetry_enabled'; // unset = never asked
const KEY_LAST = 'telemetry_last_report'; // last SUCCESSFUL send
const KEY_LAST_ERROR = 'telemetry_last_error';// last FAILED attempt — see getLastError
/*
* Where reports go. TWO independent destinations, deliberately:
*
* SCREENTINKER_ENDPOINT hard-wired, and reached only when the operator has switched sharing on.
* Not overridable an "override" that silently redirected the shared
* report would make the opt-in mean something different from what it says.
*
* TELEMETRY_EXTRA_ENDPOINT an operator's OWN collector, for their own fleet numbers. Additional,
* never a replacement, and named so it cannot be mistaken for one. It is
* sent independently of the sharing toggle: it is their server posting to
* their host, so our opt-in has no business gating it. An operator who
* wants internal statistics and nothing leaving for us sets this and
* leaves sharing off that combination is supported on purpose.
*/
const SCREENTINKER_ENDPOINT = 'https://stats.screentinker.com/api/telemetry/report';
const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily; this is a count, not a metric
const FIRST_REPORT_DELAY_MS = 5 * 60 * 1000; // let boot settle before any outbound call
let timer = null;
/* The instance's own id, minted on first read. Stable for the life of the install. */
function instanceId() {
let id = appSettings.get(KEY_ID, null);
if (!id) {
id = crypto.randomUUID();
appSettings.set(KEY_ID, id);
}
return id;
}
/*
* 'unasked' | 'on' | 'off'. The distinction matters: 'unasked' is what the prompt keys on, and a
* declined install must be remembered as 'off' rather than falling back to 'unasked' and being
* asked again on every update re-prompting is how telemetry gets patched out by annoyed admins.
*/
function state() {
const v = appSettings.get(KEY_ENABLED, undefined);
if (v === undefined) return 'unasked';
return (v === 'true' || v === '1') ? 'on' : 'off';
}
function setEnabled(enabled) {
appSettings.setBool(KEY_ENABLED, !!enabled);
return state();
}
/* Every field that leaves this install, built in one place so it can be audited at a glance. */
function payload(db) {
return {
instance_id: instanceId(),
version: require('../version'),
screen_count: countScreens(db),
};
}
// Devices that have actually been paired — a provisioning row nobody ever connected is not a
// screen, and counting it would overstate exactly the number this exists to state honestly.
function countScreens(db) {
try {
return db.prepare('SELECT COUNT(*) AS c FROM devices WHERE device_token IS NOT NULL').get().c;
} catch (_) {
return 0;
}
}
/* The address an operator may need to allowlist for the shared report. Hard-wired. */
function endpoint() { return SCREENTINKER_ENDPOINT; }
/* The operator's own collector, if they configured one. Null when they have not. */
function extraEndpoint() { return process.env.TELEMETRY_EXTRA_ENDPOINT || null; }
/*
* Everywhere this report is going, right now, and why so the UI can list every destination
* rather than implying there is only one. Sharing gates OUR endpoint alone.
*/
function destinations() {
const out = [];
if (state() === 'on') out.push({ url: endpoint(), kind: 'screentinker' });
const extra = extraEndpoint();
if (extra) out.push({ url: extra, kind: 'extra' });
return out;
}
/* What was last sent, and when. Surfaced in Settings so an operator can check rather than trust. */
function getLastReport() {
const raw = appSettings.get(KEY_LAST, null);
if (!raw) return null;
try { return JSON.parse(raw); } catch (_) { return null; }
}
/*
* The last FAILED attempt, kept separately from the last success.
*
* A self-hosted server frequently sits behind egress filtering, so "enabled but nothing arrives"
* is the normal failure and it is otherwise completely silent the operator sees "nothing has
* been sent" and has no way to tell a blocked firewall from a broken feature. Recording the
* failure lets the UI name the host that needs unblocking instead.
*/
function getLastError() {
const raw = appSettings.get(KEY_LAST_ERROR, null);
if (!raw) return null; // '' is how a success clears it
try { return JSON.parse(raw); } catch (_) { return null; }
}
/*
* Send one report. Returns {sent:false, reason} rather than throwing a stats call must never be
* able to affect the running server, so every failure path here is quiet and local.
*/
async function postTo(url, body) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
return res.ok ? { sent: true } : { sent: false, reason: `http_${res.status}` };
} catch (err) {
// Offline, DNS failure, blocked egress — all normal for a self-hosted box, none of them news
// in the log, but all worth surfacing in the UI so the operator can act on it.
return { sent: false, reason: err && err.name === 'TimeoutError' ? 'timeout' : 'network' };
}
}
async function report(db, { urls = null } = {}) {
// `urls` is a test seam. Normal callers get destinations() — sharing gates ours, an operator's
// own collector is independent of it.
const targets = urls || destinations();
if (!targets.length) return { sent: false, reason: 'not_enabled', results: [] };
const now = () => Math.floor(Date.now() / 1000);
const body = payload(db);
// Every destination is attempted, independently. One unreachable collector must not stop the
// other from receiving — a blocked corporate firewall on their host should not cost us the
// shared count, and our endpoint being down should not cost them their own fleet numbers.
const results = [];
for (const t of targets) results.push({ ...t, ...(await postTo(t.url, body)) });
const failed = results.filter(r => !r.sent);
if (results.some(r => r.sent)) appSettings.set(KEY_LAST, JSON.stringify({ at: now(), body, results }));
// Keep only a LIVE complaint: record what is still failing, and clear it once nothing is.
appSettings.set(KEY_LAST_ERROR, failed.length
? JSON.stringify({ at: now(), reason: failed[0].reason, url: failed[0].url, failed })
: '');
return { sent: failed.length === 0, results, body, reason: failed[0]?.reason };
}
function start(db) {
if (timer) return;
const tick = () => { report(db).catch(() => {}); };
setTimeout(tick, FIRST_REPORT_DELAY_MS).unref?.();
timer = setInterval(tick, REPORT_INTERVAL_MS);
timer.unref?.(); // never hold the process open for a stats timer
}
function stop() { if (timer) { clearInterval(timer); timer = null; } }
module.exports = { instanceId, state, setEnabled, payload, report, endpoint, extraEndpoint, destinations, getLastReport, getLastError, start, stop };

View file

@ -0,0 +1,108 @@
'use strict';
// Retroactive thumbnail generation. Ingest-time generation (lib/content-ingest) is
// best-effort by contract, so a row silently ends up without a thumbnail whenever it
// fails — most commonly video uploads on a host without ffmpeg installed, plus any
// content from before thumbnails existed. Those rows previously stayed bare forever:
// nothing ever looked at them again.
//
// This sweep runs once per boot (kicked off from server.js shortly after listen),
// finds local image/video rows with no thumbnail, and re-derives metadata for each.
// One file at a time with a pause between files: the point is to heal the library
// eventually, not to win a race against playback serving on the same box.
//
// Idempotent by construction — a generated thumbnail fills thumbnail_path, which
// removes the row from the next boot's query. Video rows are skipped wholesale when
// ffmpeg/ffprobe are missing (the [MEDIA] startup diagnostic already told the
// operator) rather than paying a doomed ffmpeg spawn per file per boot.
//
// The sweep can take a long time on a large bare library, and the replace/delete
// flows may touch the same rows meanwhile. Two consequences handled below:
// - the row UPDATE re-checks that thumbnail_path is STILL empty, so a thumbnail
// written concurrently by PUT /:id/replace is never clobbered with a frame of
// the pre-replace bytes;
// - a row that vanished (or was replaced) mid-derive gets its just-written thumb
// file removed again — contentDir has no garbage collector.
const path = require('path');
const fs = require('fs');
const { db } = require('../db/database');
const config = require('../config');
const { deriveMediaMetadata } = require('./content-ingest');
const { mediaToolStatus } = require('./media-tools');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Undecodable files fail again every boot (thumbnail_path is the only idempotency
// marker — deliberately, so installing ffmpeg heals them). A video can burn two 15s
// subprocess timeouts, so a library with hundreds of corrupt clips must not turn
// every boot into an hour of doomed ffmpeg spawns: stop after this many failures
// and let the next boot take another bite.
const FAILURE_CAP = 25;
async function backfillMissingThumbnails({ delayMs = 500 } = {}) {
const tools = await mediaToolStatus();
// filepath != '' and no remote_url: only content whose bytes live in contentDir.
// Remote/YouTube/embed rows either carry a remote thumbnail URL already or have
// nothing local to derive one from.
const rows = db.prepare(`
SELECT id, filepath, mime_type FROM content
WHERE (thumbnail_path IS NULL OR thumbnail_path = '')
AND filepath != ''
AND (remote_url IS NULL OR remote_url = '')
AND (mime_type LIKE 'image/%' OR mime_type LIKE 'video/%')
`).all();
const stats = { scanned: rows.length, generated: 0, skipped: 0, failed: 0, aborted: false };
const updateStmt = db.prepare(`
UPDATE content SET thumbnail_path = ?,
width = COALESCE(width, ?), height = COALESCE(height, ?),
duration_sec = COALESCE(duration_sec, ?)
WHERE id = ? AND (thumbnail_path IS NULL OR thumbnail_path = '')
`);
// Thumbnail failed but the probe worked: keep the dims/duration (item-duration and
// orientation handling consume them) without marking the row healed — it stays
// eligible for a thumbnail retry next boot.
const metadataStmt = db.prepare(`
UPDATE content SET width = COALESCE(width, ?), height = COALESCE(height, ?),
duration_sec = COALESCE(duration_sec, ?)
WHERE id = ?
`);
for (const row of rows) {
if (stats.failed >= FAILURE_CAP) {
stats.aborted = true;
console.warn(`[MEDIA] thumbnail backfill: stopping after ${stats.failed} failures — will retry remaining rows next boot`);
break;
}
const isVideo = row.mime_type.startsWith('video/');
if (isVideo && (!tools.ffmpeg || !tools.ffprobe)) { stats.skipped++; continue; }
const storedName = path.basename(row.filepath);
const sourcePath = path.join(config.contentDir, storedName);
if (!fs.existsSync(sourcePath)) { stats.skipped++; continue; }
try {
const { width, height, durationSec, thumbnailPath } =
await deriveMediaMetadata(sourcePath, storedName, row.mime_type);
if (thumbnailPath) {
const res = updateStmt.run(thumbnailPath, width, height, durationSec, row.id);
if (res.changes === 0 && thumbnailPath !== storedName) {
// Row deleted/replaced while we were deriving. Don't leave the freshly
// written file orphaned. (thumbnailPath === storedName is the SVG
// self-thumbnail case — that file IS the content, never remove it.)
try { fs.unlinkSync(path.join(config.contentDir, path.basename(thumbnailPath))); } catch { /* best-effort */ }
}
stats.generated += res.changes;
} else {
if (width || height || durationSec) metadataStmt.run(width, height, durationSec, row.id);
stats.failed++; // deriveMediaMetadata already warned with the reason
}
} catch (e) {
stats.failed++;
console.warn(`Thumbnail backfill failed for ${row.id}: ${e.message}`);
}
await sleep(delayMs);
}
return stats;
}
module.exports = { backfillMissingThumbnails };

View file

@ -0,0 +1,62 @@
'use strict';
/*
* Precedence for PRERELEASE identifiers the `-alpha11` half of `1.9.34-alpha11`.
*
* WHY THIS EXISTS: a plain string compare is what semver specifies for a single alphanumeric
* identifier, and it is wrong for how this project actually names builds. `"alpha11" < "alpha8"`
* because `'1' < '8'`, so EVERY build from alpha10 onward sorted below alpha8 and alpha9. The OTA
* check then answered `client-newer` and refused to offer the update at all a fleet on alpha8
* could not be moved forward, silently, with the server reporting the newer build as `latest` in
* the same breath. Two comparators carried the same assumption, both with a comment saying lexical
* was "fine for our naming"; it was fine only while the counter stayed below 10.
*
* The rule here is natural ordering: split each identifier into digit and non-digit runs and
* compare digit runs NUMERICALLY. That gives what a human means by the name alpha8 < alpha9 <
* alpha10 < alpha11 while leaving everything else alphabetical, so beta still outranks alpha and
* rc still outranks beta.
*
* Dot-separated identifiers are compared one at a time per semver, and a shorter run of identifiers
* loses when all preceding ones are equal (`alpha` < `alpha.1`), so a future move to the semver-
* correct `-alpha.11` form keeps working without another change here.
*
* Deliberately NOT handled: whether a prerelease outranks a release. That is the caller's rule
* both callers already implement it, and each has its own exceptions (ota-breaker treats the legacy
* `-patchN` scheme as released).
*/
// Compare one identifier, digit runs numerically. "alpha10" -> ["alpha", "10"].
function naturalCmp(x, y) {
const rx = String(x).match(/\d+|\D+/g) || [];
const ry = String(y).match(/\d+|\D+/g) || [];
for (let i = 0; i < Math.max(rx.length, ry.length); i++) {
const a = rx[i], b = ry[i];
if (a === undefined) return -1; // "alpha" < "alpha1"
if (b === undefined) return 1;
const aNum = /^\d+$/.test(a), bNum = /^\d+$/.test(b);
if (aNum && bNum) {
// Numeric, so 10 beats 8 — the whole point of this file.
if (Number(a) !== Number(b)) return Number(a) < Number(b) ? -1 : 1;
} else if (a !== b) {
// A digit run sorts below a word run, matching semver's numeric-identifiers-first rule.
if (aNum !== bNum) return aNum ? -1 : 1;
return a < b ? -1 : 1;
}
}
return 0;
}
/* Full prerelease precedence: dot-separated identifiers, each compared naturally. */
function preCmp(a, b) {
if (a === b) return 0;
const as = String(a).split('.'), bs = String(b).split('.');
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
if (as[i] === undefined) return -1; // "alpha" < "alpha.1"
if (bs[i] === undefined) return 1;
const c = naturalCmp(as[i], bs[i]);
if (c !== 0) return c;
}
return 0;
}
module.exports = { preCmp, naturalCmp };

187
server/lib/wall-geometry.js Normal file
View file

@ -0,0 +1,187 @@
'use strict';
/*
* Video-wall tile geometry: where does one panel's stage sit inside its own viewport?
*
* #236. Before per-panel rotation existed, the wall canvas was secretly drawn in FRAMEBUFFER
* space, not in the space a person standing in front of the wall sees. That is invisible while
* every panel is mounted the normal way up, and actively misleading the moment one isn't: a
* customer with two portrait-mounted panels SIDE BY SIDE had to stack them VERTICALLY in the
* editor and ship a pre-rotated copy of every video, because the editor was really asking
* "where is this panel's 1920x1080 framebuffer?" while showing a picture that read as
* "where is this panel on the wall?".
*
* The model here: the canvas is WALL space x right, y down, as the audience sees it. A panel
* mounted turned occupies a turned rect (a portrait-mounted 1920x1080 panel is a tall tile), and
* `rotation` says how far the panel's own image has to be turned to come out upright on the wall.
*
* rotation is degrees CLOCKWISE that the content is rotated WITHIN the framebuffer the same
* convention as the per-device `orientation` field (lib/orientation-style.js: portrait === 90).
* Equivalently: the panel is physically mounted rotated that far ANTI-clockwise. Picking the
* opposite sign here would have been just as self-consistent and would have silently disagreed
* with the device orientation setting, so it is pinned by test.
*
* The arithmetic lives here, once, because four players have to agree on it to the pixel the web
* player, Tizen, Android and BrightSign all render the same frame across panels that share a seam.
* A half-pixel of disagreement between two of them is a visible line down the middle of the wall.
*/
const VALID_ROTATIONS = [0, 90, 180, 270];
/**
* Coerce whatever the DB / payload carried into a rotation we can render.
* Anything unrecognised falls back to 0: a bad value should leave the wall looking exactly as it
* was drawn, not turn one panel of a live wall on its side.
*/
function normalizeWallRotation(value) {
const n = Number(value);
return VALID_ROTATIONS.includes(n) ? n : 0;
}
/**
* Which orientation should the player apply to its container while it is a member of a wall?
*
* The two settings describe the SAME physical fact (this panel is mounted turned), so applying
* both turns the content twice and lands it sideways and off-screen. When the wall carries a
* rotation it is authoritative the tile geometry below already accounts for the mounting so
* the container transform is suppressed. Rotation 0 changes nothing, which is what keeps every
* wall that exists today behaving exactly as it does today.
*
* @param {string} orientation the device's own orientation setting
* @param {number} wallRotation per-panel wall rotation (0/90/180/270)
* @returns {string} the orientation the player should actually apply
*/
function orientationForWallMember(orientation, wallRotation) {
return normalizeWallRotation(wallRotation) === 0 ? (orientation || 'landscape') : 'landscape';
}
/**
* Tile geometry in viewport-relative units.
*
* The stage is the WHOLE player rect; the viewport crops it to this panel's slice. So the stage is
* usually much larger than the screen and usually positioned partly off-view that is the design,
* not a bug.
*
* Returned as unit-tagged numbers so the same numbers can drive CSS (vw/vh) and Android
* (displayMetrics px) without either re-deriving the maths.
*
* @param {{x:number,y:number,w:number,h:number}} screenRect this panel's rect in wall space
* @param {{x:number,y:number,w:number,h:number}} playerRect the content rect in wall space
* @param {number} rotation 0/90/180/270
* @returns {null|{rotation:number,w:number,wAxis:'x'|'y',h:number,hAxis:'x'|'y',cx:number,cy:number}}
* w/h the stage box BEFORE rotation, as a multiple of the viewport axis named by wAxis/hAxis
* ('x' = viewport width, 'y' = viewport height).
* cx/cy where the box's centre goes, as a fraction of viewport width / height.
* null when the screen rect has no area (nothing sane to map onto a zero-sized panel).
*/
function wallStageGeometry(screenRect, playerRect, rotation) {
const s = screenRect, p = playerRect;
if (!s || !p || !s.w || !s.h) return null;
const rot = normalizeWallRotation(rotation);
// The player rect's centre, as a fraction of this panel's rect. Working from the CENTRE (not the
// top-left) is what makes all four rotations one formula: rotation moves a corner but leaves a
// centre where it is.
const nx = (p.x + p.w / 2 - s.x) / s.w;
const ny = (p.y + p.h / 2 - s.y) / s.h;
const fw = p.w / s.w; // stage extent along wall X, in units of the panel's wall width
const fh = p.h / s.h; // stage extent along wall Y, in units of the panel's wall height
// A quarter turn swaps which viewport axis each wall axis is measured against: on a panel mounted
// sideways, the wall's horizontal is the framebuffer's vertical. Getting this wrong is the classic
// "the wall is right but every tile is squashed" symptom.
const quarter = rot === 90 || rot === 270;
const wAxis = quarter ? 'y' : 'x';
const hAxis = quarter ? 'x' : 'y';
// Where wall-space (nx, ny) lands in framebuffer-normalised (across, down) coordinates.
// Derivation: rotating content by `rot` clockwise inside the framebuffer sends the wall's
// top-left corner to the framebuffer corner listed, and the wall axes to the framebuffer axes
// listed. Each case is pinned by a test.
let cx, cy;
if (rot === 90) {
// wall +X -> framebuffer down, wall +Y -> framebuffer left; wall origin at framebuffer top-right
cx = 1 - ny; cy = nx;
} else if (rot === 180) {
cx = 1 - nx; cy = 1 - ny;
} else if (rot === 270) {
// wall +X -> framebuffer up, wall +Y -> framebuffer right; wall origin at framebuffer bottom-left
cx = ny; cy = 1 - nx;
} else {
cx = nx; cy = ny;
}
return { rotation: rot, w: fw, wAxis, h: fh, hAxis, cx, cy };
}
/**
* The same geometry as CSS values, ready to assign onto element.style.
* Empty string means "clear it" a half-reset leaves a stage stuck at the previous wall's size.
*
* @returns {{left:string,top:string,width:string,height:string,transform:string,transformOrigin:string}}
* or null when the screen rect has no area.
*/
function wallStageStyle(screenRect, playerRect, rotation) {
const s = screenRect, p = playerRect;
if (!s || !p || !s.w || !s.h) return null;
// Unrotated walls take the ORIGINAL top-left expression verbatim, not the centre-based one below.
// The two are algebraically equal but not bit-for-bit equal in floating point, and every wall in
// the field today is rotation 0. An operator upgrading must not find a hairline seam appear down
// a wall that was aligned yesterday, so this path is deliberately left untouched.
if (normalizeWallRotation(rotation) === 0) {
return {
left: (((p.x - s.x) / s.w) * 100) + 'vw',
top: (((p.y - s.y) / s.h) * 100) + 'vh',
width: ((p.w / s.w) * 100) + 'vw',
height: ((p.h / s.h) * 100) + 'vh',
transform: '',
transformOrigin: '',
};
}
const g = wallStageGeometry(s, p, rotation);
const unit = (axis) => (axis === 'x' ? 'vw' : 'vh');
return {
left: (g.cx * 100) + 'vw',
top: (g.cy * 100) + 'vh',
width: (g.w * 100) + unit(g.wAxis),
height: (g.h * 100) + unit(g.hAxis),
// translate BEFORE rotate: transform functions apply right-to-left, so the box turns about its
// own centre and THEN that centre is moved into place. Reversed, the offset is rotated too and
// the tile lands on the wrong side of the panel (the same trap as orientation-style.js).
transform: 'translate(-50%, -50%) rotate(' + g.rotation + 'deg)',
transformOrigin: 'center center',
};
}
/**
* The wall footprint of a panel whose framebuffer is renderW x renderH, once mounted at `rotation`.
* The editor sizes new tiles with this so a portrait-mounted 1920x1080 panel is drawn as the tall
* rect it physically is which is the whole point of #236.
*/
function rotatedFootprint(renderW, renderH, rotation) {
const rot = normalizeWallRotation(rotation);
return (rot === 90 || rot === 270) ? { w: renderH, h: renderW } : { w: renderW, h: renderH };
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
VALID_ROTATIONS,
normalizeWallRotation,
orientationForWallMember,
wallStageGeometry,
wallStageStyle,
rotatedFootprint,
};
}
if (typeof window !== 'undefined') {
window.WallGeometry = {
VALID_ROTATIONS,
normalizeWallRotation,
orientationForWallMember,
wallStageGeometry,
wallStageStyle,
rotatedFootprint,
};
}

1031
server/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
{
"name": "screentinker",
"version": "1.9.29-rc4",
"version": "1.9.36",
"license": "MIT",
"description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js",
"scripts": {
@ -11,20 +12,21 @@
},
"dependencies": {
"@azure/msal-node": "^5.2.1",
"@jsquash/avif": "^1.3.0",
"@jsquash/webp": "^1.5.0",
"archiver": "^7.0.1",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3",
"better-sqlite3": "12.9.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^8.3.1",
"google-auth-library": "^10.6.2",
"helmet": "^8.1.0",
"jimp": "^1.6.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"nodemailer": "^9.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"sharp": "^0.35.3",
"socket.io": "^4.7.2",
"stripe": "^20.4.1",
"unzipper": "^0.12.3",
@ -32,7 +34,8 @@
},
"devDependencies": {
"js-yaml": "^4.2.0",
"socket.io-client": "^4.8.3",
"puppeteer-core": "^24.43.1"
"puppeteer-core": "^24.43.1",
"sharp": "^0.35.3",
"socket.io-client": "^4.8.3"
}
}

View file

@ -24,6 +24,12 @@
try { return Date.now(); } catch (e) { return new Date().getTime(); }
}
// Live subscribers (the player's set_debug sink). Kept HERE rather than in the player
// script so entries recorded during boot -- before the player has even parsed -- reach a
// subscriber that registers later, via the backlog in window.__debugLog.
var subs = [];
var notifying = false;
function pushLog(entry) {
try {
entry.t = nowMs();
@ -32,8 +38,22 @@
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
}
} catch (e) { /* we are the safety net; do not crash */ }
// Reentrancy guard, and it is not theoretical: this runs inside the console wrapper
// below, so a subscriber that logs anything at all -- directly, or through a library --
// would call back in here and recurse until the stack gave out. The player would die of
// its own diagnostics.
if (notifying) return;
notifying = true;
try {
for (var s = 0; s < subs.length; s++) {
try { subs[s](entry); } catch (e) { /* one bad subscriber must not eat the rest */ }
}
} finally { notifying = false; }
}
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
window.__debugLog_subscribe = function (fn) {
try { if (typeof fn === 'function') subs.push(fn); } catch (e) {}
};
pushLog({
type: 'init',
@ -126,6 +146,26 @@
<title>ScreenTinker Player</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
/*
* ONE KNOB for every pre-playback screen (setup, pairing, status, the audio prompt).
*
* A signage panel is read from across a room, so the thing that has to stay constant is
* ANGULAR size, not pixel size — and a CSS pixel covers a quarter of the screen area on a 4K
* panel that it does on 1080p, a sixteenth on 8K. A 72px pairing code that fills the wall on a
* 1080p screen is a smudge on an 8K one, which is exactly the complaint this fixes.
*
* So every size on those screens is a rem against this root: 1rem = 10px at 1080p, 20px at 4K,
* 40px at 8K — the same apparent size at the same viewing distance, at any panel resolution.
*
* vmin rather than vw, because portrait-mounted panels are common here and vw would render a
* 1080x1920 screen at half size. Clamped at both ends so a dashboard preview iframe or a
* laptop window stays legible instead of microscopic, and an ultrawide does not get silly.
*
* Nothing outside these screens uses rem, so this cannot reach playback content — zones,
* images and video are laid out in % and px by the layout engine and are untouched.
*/
html { font-size: clamp(7px, 0.926vmin, 56px); }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
/* Setup Screen */
@ -133,23 +173,25 @@
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
}
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
#setupScreen .form { width: 400px; max-width: 90vw; }
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
#setupScreen h1 { font-size: 3.6rem; color: #3b82f6; margin-bottom: 0.8rem; }
#setupScreen .subtitle { color: #94a3b8; font-size: 1.6rem; margin-bottom: 4.8rem; }
#setupScreen .form { width: 40rem; max-width: 90vw; }
#setupScreen label { display: block; font-size: 1.4rem; color: #94a3b8; margin-bottom: 0.8rem; }
#setupScreen input { width: 100%; padding: 1.2rem; background: #0f172a; border: 0.1rem solid #334155;
border-radius: 0.8rem; color: #f1f5f9; font-size: 1.6rem; margin-bottom: 2.4rem; outline: none; }
#setupScreen input:focus { border-color: #3b82f6; }
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
#setupScreen button { width: 100%; padding: 1.2rem; background: #3b82f6; color: white;
border: none; border-radius: 0.8rem; font-size: 1.6rem; font-weight: 600; cursor: pointer; }
#setupScreen button:hover { background: #2563eb; }
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 12px; margin: 24px 0; }
.pairing-hint { color: #64748b; font-size: 14px; }
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
/* The number someone is squinting at from the far side of a shop. It gets the most room the
screen can give it, which is why it is the largest multiple here. */
.pairing-code { font-size: 7.2rem; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 1.2rem; margin: 2.4rem 0; }
.pairing-hint { color: #64748b; font-size: 1.4rem; }
.status-msg { color: #94a3b8; font-size: 1.4rem; margin-top: 1.6rem; }
.spinner { width: 4rem; height: 4rem; border: 0.3rem solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 2.4rem auto; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Player */
@ -201,8 +243,8 @@
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
}
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
#statusOverlay p { font-size: 16px; }
#statusOverlay h2 { color: #3b82f6; font-size: 2.8rem; margin-bottom: 0.8rem; }
#statusOverlay p { font-size: 1.6rem; }
</style>
</head>
<body>
@ -241,6 +283,7 @@
<script src="/player/schedule-eval.js"></script>
<script src="/player/media-mute.js"></script>
<script src="/player/orientation-style.js"></script>
<script src="/player/wall-geometry.js"></script>
<script src="/player/player-media-health.js"></script>
<!-- feat/transition-engine: WebGL transition runtime (renderer + shaders). Optional; if it fails to
load the player just hard-cuts. Not deferred so it's ready before the first content swap. -->
@ -492,13 +535,233 @@
// ==================== State ====================
let socket = null;
let config = getConfig();
/*
* Does a <video> on this platform actually yield pixels to a canvas? Cached: it is a property
* of the platform, not of the clip. Consumed by videoCompositingAvailable() ~2900 lines below.
*
* DECLARED HERE, AND IT MUST STAY HERE. It used to live next to its function, and that BRICKED
* a player: boot restores the cached playlist and renders item 0 from a call site far above
* that point, so when item 0 was a video carrying a transition, `isVideoBufferable` read this
* binding before its `let` had executed. That is a TemporalDeadZone *throw*, not a `null` —
* the player died during boot, every boot, and because the offending playlist came from the
* LOCAL cache it never stayed up long enough to receive a corrected one. A permanent brick,
* recoverable only by clearing the device's storage.
*
* Reproduced on a BrightSign XT245 on 2026-08-07: "Cannot access '_videoCompositingOk' before
* initialization @ player:3730". Any web-based player could hit it — it is not BrightSign-specific.
*/
let _videoCompositingOk = null;
// feat/offline-cause-log: connectivity-report state — track in-session disconnects so the next
// reconnect can report WHY it was gone (local link lost vs server/upstream unreachable). A browser
// can't see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false.
let disconnectedAt = 0; // Date.now() at the first disconnect of the current gap (0 = not in a gap)
let linkLostDuringGap = false; // navigator went offline at any point during the gap
// Set when register() is REFUSED (not merely pending). A runtime can expose navigator
// .serviceWorker and still decline to run one — a real BrightSign widget does exactly that —
// and the display must stop claiming an offline capability it cannot honour.
let swRegistrationFailed = false;
// feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
// Best-effort + auth-guarded (the reconnected socket is authenticated by the time we emit).
/*
* BrightSign host diagnostics, forwarded as if the page had produced them.
*
* The host sees what the page cannot — the uptime, the wired IP, which volume it booted from,
* whether a staged package applied — and everything it knew used to go to a serial console.
* On a panel on a wall that is the same as knowing nothing: a script that failed to compile
* looked, from here, exactly like a player that never started.
*
* Wired once, guarded, and a no-op on every other platform: the hooks only exist on the
* BrightSign bridge, so a browser skips this entirely.
*/
function wireHostDiagnostics() {
try {
if (!BS || typeof BS.onHostLog !== 'function') return;
BS.onHostLog((line) => {
try {
// While the live stream is ON the console.log below already reaches the dashboard
// through the debug sink, so emitting here too would show every host line twice.
// Host lines still go out unconditionally when it is OFF: the boot story is the one
// diagnostic nobody can ask for in advance, because it is over before the operator
// has a device to open.
if (!remoteDebug && socket?.connected && config.deviceId) {
socket.emit('device:log', {
device_id: config.deviceId,
tag: line.tag, level: line.level, message: line.message
});
}
console.log(`[host/${line.tag}] ${line.message}`);
} catch (e) { /* diagnostics must never break playback */ }
});
BS.onHostEvent((ev) => emitDeviceEvent(ev.event, ev.reason, ev.detail));
} catch (e) { /* a bridge that throws here must not stop the player starting */ }
}
/* ==================== Live remote debug (`set_debug`) ====================
*
* The dashboard's per-device "Debug logging" checkbox sends a `set_debug` command and opens a
* live panel. The Android player has honoured it for a long time -- DebugLog.* mirrors its
* tagged lines to the dashboard while the box is ticked. The WEB player never implemented the
* command at all, so the panel opened, streamed nothing, and read as a display with nothing to
* say. In a browser that barely mattered: press F12. On a BrightSign it is the whole story --
* there is no console, no adb, no logcat, and this panel is the only way to watch what the
* player thinks it is doing.
*
* Rather than hand-instrument eighty-odd call sites to match Android's tag-by-tag, this streams
* the ring buffer the error trap at the top of <head> has always filled: every console.log/
* warn/error, every uncaught error with file:line and stack, every unhandled rejection, every
* failed resource load, plus the host's own boot report on BrightSign. Turning the stream on
* also FLUSHES that backlog, so the operator sees the failure that happened BEFORE they opened
* the screen -- which is the case they actually came to investigate, and which tailing a log
* cannot give them.
*/
let remoteDebug = false; // is the stream on right now
let debugSubscribed = false; // ring-buffer subscription is permanent once made
let debugAutoOffTimer = null;
let debugWindowAt = 0, debugWindowCount = 0, debugSuppressed = 0;
// Fed by console.*, so the ceiling has to assume the worst: a video that fails to decode and is
// retried every frame turns a diagnostic aid into a flood down the same socket as playback.
const DEBUG_MAX_LINES_PER_SEC = 40;
// Ticking the box and walking away must not leave a panel streaming for the rest of the week.
// The dashboard turns it off on teardown, but a closed laptop or a killed tab never sends that,
// and the device is the only party in a position to be sure.
const DEBUG_AUTO_OFF_MS = 30 * 60 * 1000;
function debugSend(tag, level, message) {
if (!socket?.connected || !config.deviceId) return;
try {
socket.emit('device:log', {
device_id: config.deviceId,
tag: String(tag || 'player').slice(0, 64),
level: String(level || 'i').slice(0, 8),
message: String(message == null ? '' : message).slice(0, 2000),
});
} catch (e) { /* a diagnostic that throws is worse than one that is missing */ }
}
/*
* One live line, rate-limited. Over the cap the lines are COUNTED, not queued -- an operator
* needs to know output was dropped far more than they need the four hundredth copy of one
* message, and a queue would go on replaying the flood after it stopped.
*/
function debugEmitLine(tag, level, message) {
if (!remoteDebug) return;
const now = Date.now();
if (now - debugWindowAt >= 1000) {
debugWindowAt = now;
const dropped = debugSuppressed;
debugWindowCount = 0;
debugSuppressed = 0;
if (dropped > 0) {
debugWindowCount++;
debugSend('debug', 'w', `${dropped} line(s) suppressed — rate limit`);
}
}
if (debugWindowCount >= DEBUG_MAX_LINES_PER_SEC) { debugSuppressed++; return; }
debugWindowCount++;
debugSend(tag, level, message);
}
// The player already prefixes most of its console lines with [wall], [bs], [group-sync] and so
// on -- the same shape Android's tags have -- so lift that into the tag column and the panel
// reads the same on both platforms instead of being one long undifferentiated column.
const DEBUG_TAG_RE = /^\s*\[([a-zA-Z0-9/_.-]{1,24})\]\s*/;
function debugTagFor(entry) {
if (entry.tag) return String(entry.tag);
const m = DEBUG_TAG_RE.exec(entry.message || '');
if (m) return m[1];
if (entry.type === 'error' || entry.type === 'rejection') return 'error';
if (entry.type === 'timing' || entry.type === 'init') return entry.type;
return 'player';
}
function debugLevelFor(entry) {
if (entry.level) return entry.level;
if (entry.type === 'console.error' || entry.type === 'error' || entry.type === 'rejection') return 'e';
if (entry.type === 'console.warn') return 'w';
return 'i';
}
// The non-console entry types carry structured fields rather than a message, and they are the
// valuable ones -- an uncaught error is worth nothing without its file, line and stack.
function debugMessageFor(entry) {
let msg = entry.message || '';
if (entry.type === 'init') {
msg = `page ${entry.url || '?'} — screen ${entry.sw || '?'}x${entry.sh || '?'} — ${entry.ua || ''}`;
} else if (entry.type === 'timing') {
msg = `${entry.event} +${entry.sinceInit}ms`;
} else if (entry.type === 'error' || entry.type === 'rejection') {
if (entry.source) msg += ` @${entry.source}:${entry.line || 0}:${entry.col || 0}`;
if (entry.stack) msg += ` | ${String(entry.stack).replace(/\s+/g, ' ').slice(0, 400)}`;
} else if (!msg) {
msg = entry.type || '';
}
return msg.replace(DEBUG_TAG_RE, ''); // already lifted into the tag column
}
function debugSink(entry) {
if (!remoteDebug || !entry || entry.__stSent) return;
entry.__stSent = true;
debugEmitLine(debugTagFor(entry), debugLevelFor(entry), debugMessageFor(entry));
}
// What the operator needs before the first line means anything: which player this is, how big
// the screen really is, and -- on BrightSign -- that they are talking to the host at all.
function debugPlatformLine() {
const bits = [];
try { bits.push(`screen ${screen.width}x${screen.height}@${window.devicePixelRatio || 1}x`); } catch (e) {}
try { if (BS && BS.isBrightSign && BS.isBrightSign()) bits.push('BrightSign'); } catch (e) {}
try { bits.push(navigator.userAgent); } catch (e) {}
return bits.join(' — ');
}
/*
* Replay what is already in the buffer. Deliberately bypasses the per-second cap: it is a
* one-shot burst bounded by the ring buffer itself (200 entries), and throttling it would drop
* exactly the history the operator turned the stream on to read.
*/
function debugFlushBacklog() {
let buf = [];
try { buf = (window.__debugLog || []).filter((e) => e && !e.__stSent); } catch (e) { return; }
debugSend('debug', 'i', `--- replaying ${buf.length} buffered line(s) from before the stream opened ---`);
const now = Date.now();
for (const entry of buf) {
entry.__stSent = true;
// The dashboard timestamps each line on arrival, so a replayed line would claim to have
// happened just now. Carry the real age in the text instead of quietly lying about when
// the crash was.
const age = entry.t ? `(-${((now - entry.t) / 1000).toFixed(1)}s) ` : '';
debugSend(debugTagFor(entry), debugLevelFor(entry), age + debugMessageFor(entry));
}
debugSend('debug', 'i', '--- end of backlog, now live ---');
}
function setRemoteDebug(on) {
if (debugAutoOffTimer) { clearTimeout(debugAutoOffTimer); debugAutoOffTimer = null; }
if (!on) {
if (remoteDebug) debugEmitLine('debug', 'i', 'Remote debug logging OFF');
remoteDebug = false;
return;
}
const wasOn = remoteDebug;
remoteDebug = true;
debugWindowAt = 0; debugWindowCount = 0; debugSuppressed = 0;
if (!debugSubscribed) {
debugSubscribed = true;
try { window.__debugLog_subscribe?.(debugSink); } catch (e) {}
}
if (!wasOn) {
debugSend('debug', 'i', `Remote debug logging ON — ${debugPlatformLine()}`);
debugFlushBacklog();
}
debugAutoOffTimer = setTimeout(() => {
debugAutoOffTimer = null;
debugEmitLine('debug', 'w', `Remote debug logging auto-disabled after ${Math.round(DEBUG_AUTO_OFF_MS / 60000)} min`);
remoteDebug = false;
}, DEBUG_AUTO_OFF_MS);
}
function emitDeviceEvent(type, reason, detail) {
try {
if (!socket?.connected || !config.deviceId) return;
@ -608,6 +871,51 @@
// playback muted).
let userHasInteracted = false;
let advanceTimer = null;
/*
* Arm the single advance timer. ALWAYS clear the outgoing one first.
*
* `advanceTimer` is one slot by design — renderContent clears it on every item change — but a
* dozen call sites used to write `advanceTimer = setTimeout(...)` directly, and a second write
* before the first fired ORPHANED the earlier timer instead of cancelling it: still pending,
* no longer referenced, so nothing could stop it. Every one of those fired, and each one
* advanced the playlist.
*/
function scheduleAdvance(fn, ms) {
if (advanceTimer) clearTimeout(advanceTimer);
advanceTimer = setTimeout(fn, ms);
return advanceTimer;
}
const MEDIA_ERR_NAME = { 1: 'ABORTED', 2: 'NETWORK', 3: 'DECODE', 4: 'SRC_NOT_SUPPORTED' };
/*
* One broken item, one skip.
*
* A media element can raise `error` several times over for a single item, and each event used
* to schedule its own `nextItem`. On a SINGLE-item playlist that merely re-played the same
* clip, which is how this was finally noticed — a BrightSign XT245 logging four errors and
* three "Playing:" lines at every loop boundary. On a real playlist the identical storm SKIPS
* one item per surplus event, silently, and the operator sees a playlist that drops content.
*
* The second guard is that a media element still able to play is not a failure. `error` fires
* with `el.error` set; an event carrying no MediaError against an element with buffered frames
* ahead of it did not fail at anything, and discarding a healthy item on that basis is worse
* than the event we are reacting to. Anything genuinely unplayable (no MediaError AND nothing
* decoded) is still skipped, so a broken clip can never stall the playlist.
*/
function mediaFailureSkip(el, label, src, mayAdvance = true) {
const err = el && el.error;
if (!err && el && el.readyState >= 3) {
console.warn(`[media] ${label} raised error with no MediaError while playable (readyState=${el.readyState}) — ignored: ${src}`);
return;
}
if (el && el.__stFailed) return; // however many events it raises, one skip
if (el) el.__stFailed = true;
const detail = err ? `code=${err.code} ${MEDIA_ERR_NAME[err.code] || '?'}${err.message ? ' ' + err.message : ''}` : 'no MediaError';
console.error(`[media] ${label} failed (${detail}) src=${src}`);
if (mayAdvance) scheduleAdvance(nextItem, 3000); // skip the broken item; hold the prior frame
}
// Buffered widget swap (#directory-board black-cycle): build the next widget iframe
// behind the current content and reveal it only on 'load', so a widget reload never
// blanks the screen. WIDGET_SWAP_TIMEOUT_MS reveals anyway if 'load' never fires (a
@ -807,8 +1115,8 @@
if (document.getElementById('enableAudioPrompt')) return;
const ov = document.createElement('div');
ov.id = 'enableAudioPrompt';
ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
ov.innerHTML = '<span style="font-size:20px">&#128263;</span><span>Tap to enable audio</span>';
ov.style.cssText = 'position:fixed;bottom:2.4rem;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:1.2rem 2.2rem;border-radius:0.8rem;cursor:pointer;z-index:10000;font-size:1.4rem;display:flex;gap:1rem;align-items:center;box-shadow:0 0.4rem 1.6rem rgba(0,0,0,0.4)';
ov.innerHTML = '<span style="font-size:2rem">&#128263;</span><span>Tap to enable audio</span>';
ov.addEventListener('click', () => {
unlockAudioContext();
tryUnmuteLeader();
@ -996,9 +1304,9 @@
const tapOverlay = document.createElement('div');
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
tapOverlay.innerHTML = `
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
<h1 style="color:#3b82f6;font-size:3.6rem;font-family:sans-serif;margin-bottom:1.2rem">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:1.8rem;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:1.3rem;font-family:sans-serif;margin-top:2.4rem">Audio requires user interaction</p>
`;
tapOverlay.onclick = () => {
unlockAudio();
@ -1086,6 +1394,7 @@
// straight to the UNMODIFIED renderer. No socket, no pairing.
async function renderPreviewFromUrl(url) {
PREVIEW_MODE = true;
installPreviewControlChannel(); // #239: only a preview instance can ever be steered
config.serverUrl = window.location.origin; // same-origin -> /uploads + /api/widgets resolve
const setup = document.getElementById('setupScreen');
if (setup) setup.style.display = 'none';
@ -1101,6 +1410,7 @@
}
renderPreviewBanner();
handlePlaylistUpdate(payload);
postPreviewState(); // #239: first count, in case the payload landed before the first item did
} catch (e) {
console.error('preview fetch failed', e);
showPreviewError(0);
@ -1133,6 +1443,76 @@
document.body.appendChild(div);
}
// ==================== #239 Preview transport control ====================
// Where a next/previous press lands. Kept as a pure function (no DOM, no globals) because the
// failure it prevents is arithmetic: a negative modulo or an out-of-range index mounts
// `undefined` and blanks the frame, and that is only catchable by testing the maths directly.
//
// `allows` is the per-item schedule gate. Scanning in the DIRECTION OF TRAVEL matters: skipping
// a dayparted item by falling forward would make "previous" walk forwards, which reads as the
// button being broken. Returns -1 when nothing is playable (empty list, or every item outside
// its window) so the caller can idle instead of mounting nothing.
function previewStepIndex(current, delta, length, allows) {
const n = Math.trunc(length);
if (!Number.isFinite(n) || n <= 0) return -1;
const dir = delta < 0 ? -1 : 1;
// A player that has not started yet (currentIndex === -1) or a corrupt index still has to
// produce a valid landing spot rather than propagate NaN into playlist[].
const from = Number.isFinite(current) ? ((Math.trunc(current) % n) + n) % n : 0;
for (let i = 1; i <= n; i++) {
const idx = (((from + dir * i) % n) + n) % n;
if (!allows || allows(idx)) return idx;
}
return -1;
}
// Jump the preview by one item. PREVIEW_MODE is the hard gate: a live player never installs the
// message listener below AND would refuse here anyway, so a page that frames the player can
// never steer content on a real screen.
function previewNavigate(delta) {
if (!PREVIEW_MODE) return;
const idx = previewStepIndex(currentIndex, delta, playlist.length, (i) => scheduleAllows(playlist[i]));
if (idx === -1) return;
clearTimeout(scheduleRetryTimer); // we are leaving the idle screen by hand
currentIndex = idx;
isPlaying = true;
playCurrentItem(); // re-renders, which clears the pending advance timer
}
// Tell the dashboard which item is on screen so it can show "3 of 7". targetOrigin is our own
// origin, so a third-party page that iframes the player learns nothing about the workspace's
// content from this channel.
function postPreviewState() {
if (!PREVIEW_MODE || window.parent === window) return;
const item = playlist[currentIndex];
// A multi-zone playlist plays every zone at once on independent timers — there is no single
// "current item" to step through, so say so and let the dashboard hide the controls rather
// than offer a button that does nothing visible.
const zoned = !!(layout && layout.zones && layout.zones.length > 1);
try {
window.parent.postMessage({
source: 'screentinker-player',
type: 'preview:state',
index: currentIndex,
total: playlist.length,
zoned,
name: (item && (item.filename || item.widget_name || item.title)) || null,
}, window.location.origin);
} catch (e) { /* parent went away mid-preview */ }
}
function installPreviewControlChannel() {
window.addEventListener('message', (ev) => {
if (!PREVIEW_MODE) return; // belt and braces: see previewNavigate
if (ev.origin !== window.location.origin) return; // only our own dashboard may drive us
const d = ev.data;
if (!d || d.source !== 'screentinker-preview') return;
if (d.action === 'next') previewNavigate(1);
else if (d.action === 'prev') previewNavigate(-1);
else if (d.action === 'sync') postPreviewState(); // parent (re)attached and wants the count
});
}
// #104: the always-visible honest note for webpage widgets. No auto-detection —
// an XFO-refused frame is provably indistinguishable client-side from a working
// one, so we never guess; we just tell the truth. Preview-only (never on device).
@ -1376,6 +1756,9 @@
startWatchdog(); // v4: arm-gated half-open watchdog (no-op until a heartbeat-ack arms it)
startPlaylistRefresh();
startVersionCheck();
// After the socket is up, because these forward to the SERVER — wiring them earlier would
// drop the host's boot report on the floor rather than delivering it late.
wireHostDiagnostics();
});
socket.on('device:paired', (data) => {
@ -1567,12 +1950,17 @@
if (BS && BS.reboot()) console.log('[bs] reboot requested via host');
else console.log('reboot: not supported on this player');
}
// Media volume, 0-100 from the dashboard. Applies to whatever is playing now and is
// remembered for items mounted later.
// Media volume. Applies to whatever is playing now and is remembered for items mounted
// later (see setMediaVolume). The wire parsing is volumeLevelFromCommand().
if (data.type === 'set_volume') {
const pct = Number(data.payload?.value ?? data.value);
if (isFinite(pct)) setMediaVolume(Math.max(0, Math.min(100, pct)) / 100);
const v = volumeLevelFromCommand(data);
if (v === null) console.warn('[volume] set_volume with no usable level/value:', JSON.stringify(data));
else setMediaVolume(v);
}
// The dashboard's "Debug logging" checkbox. Accepts the flag at either depth: the command
// relay wraps it in `payload`, but the queued-command replay path and the public API have
// both been seen to deliver it flat.
if (data.type === 'set_debug') setRemoteDebug(!!(data.payload?.enabled ?? data.enabled));
});
// #129: real-time mute. Apply immediately if the toggled item is the one playing now;
@ -1723,11 +2111,21 @@
}
} catch (e) { /* bundle absent: hard cuts, and we do not claim the capability */ }
// Offline caching is the service worker. Reported on support rather than on an active
// controller: the first load registers it and has no controller yet, and a display that
// re-registers on every boot would otherwise flap this capability on and off.
// Offline caching is the service worker — and the API EXISTING is not the same as it
// working. A real BrightSign XT245 on alpha has `serviceWorker` in navigator, passes this
// check, and then never even fetches sw.js: registration is refused by its widget runtime.
// It declared offline.cache to the fleet and could not cache a single byte.
//
// So the claim is made on a worker that is actually IN CONTROL. The cost is that the very
// first load under-reports (registration has happened but the worker has not claimed the
// page yet) — which is the right direction to be wrong in, and self-corrects: activation
// triggers a reload, and the next register sends the true set. `swRegistrationFailed` makes
// the negative stick on a runtime where it will never succeed, rather than waiting on a
// controller that is never coming.
try {
if ('serviceWorker' in navigator) caps.push('offline.cache');
if (!swRegistrationFailed && navigator.serviceWorker && navigator.serviceWorker.controller) {
caps.push('offline.cache');
}
} catch (e) { /* locked-down browser */ }
// Screenshots need somewhere to draw. Same-origin content and a 2d context are the real
@ -2204,24 +2602,36 @@
// the stage — which keeps the vertical position of every source pixel
// identical across devices that share a viewport height (1vh maps to
// the same physical pixel on each).
// #236: the geometry (including per-panel mounting rotation) comes from the shared rule in
// server/lib/wall-geometry.js. Kept out of here because Tizen, Android and this player must
// agree to the pixel across a seam, and because the rotated cases are only checkable by test.
function styleWallStage(stageEl) {
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
const s = wallConfig.screen_rect;
const p = wallConfig.player_rect;
if (!s.w || !s.h) return;
const left = ((p.x - s.x) / s.w) * 100;
const top = ((p.y - s.y) / s.h) * 100;
const width = (p.w / s.w) * 100;
const height = (p.h / s.h) * 100;
const st = window.WallGeometry
? window.WallGeometry.wallStageStyle(s, p, wallConfig.rotation)
// If the shared script failed to load, fall back to the unrotated rule rather than leaving
// the stage unstyled — a wrong-but-full-frame panel beats a blank one on a live wall.
: {
left: (((p.x - s.x) / s.w) * 100) + 'vw',
top: (((p.y - s.y) / s.h) * 100) + 'vh',
width: ((p.w / s.w) * 100) + 'vw',
height: ((p.h / s.h) * 100) + 'vh',
transform: '', transformOrigin: '',
};
if (!st) return;
const dev = (config.deviceId || '?').slice(0, 8);
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p) + ' rotation=' + (wallConfig.rotation || 0));
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
stageEl.style.left = left + 'vw';
stageEl.style.top = top + 'vh';
stageEl.style.width = width + 'vw';
stageEl.style.height = height + 'vh';
stageEl.style.transform = '';
console.log('[wall/render ' + dev + '] stage: left=' + st.left + ' top=' + st.top + ' width=' + st.width + ' height=' + st.height + ' transform=' + (st.transform || 'none'));
stageEl.style.left = st.left;
stageEl.style.top = st.top;
stageEl.style.width = st.width;
stageEl.style.height = st.height;
stageEl.style.transform = st.transform;
stageEl.style.transformOrigin = st.transformOrigin;
}
// No-op kept for callers that bind a resize listener (kept around in case
@ -2266,6 +2676,12 @@
// Apply orientation. #109: the PiP layer gets the SAME transform as the player so a
// corner overlay tracks the visible content (not the physical panel) in every orientation.
// #236: on a wall panel that carries its own mounting rotation, the two settings describe the
// SAME physical fact, so honouring both turns the content twice and throws it off-screen. The
// wall rotation wins there; when it is 0 (every wall in the field today) nothing changes.
const effectiveOrientation = window.WallGeometry
? window.WallGeometry.orientationForWallMember(data.orientation, data.wall_config?.rotation)
: data.orientation;
if (data.orientation) {
// On BrightSign, rotate the OUTPUT first. Video decodes onto a hardware plane the DOM
// cannot transform, so the CSS rotation below turns the images and widgets and leaves the
@ -2276,7 +2692,7 @@
// cleared or the graphics rotate twice. If it cannot, we fall through to CSS, which rotates
// most of the content rather than none of it.
if (BS && typeof BS.setOrientation === 'function' && BS.hasHost()) {
BS.setOrientation(data.orientation).then((rotatedByHost) => {
BS.setOrientation(effectiveOrientation).then((rotatedByHost) => {
if (!rotatedByHost) return; // CSS path below already ran
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
.forEach((el) => {
@ -2293,7 +2709,7 @@
// portrait content landed 420px off-screen on a 1920x1080 panel — rotated correctly and
// placed wrongly. Tizen and Android both centre the box first; the web player did not.
const st = window.OrientationStyle
? window.OrientationStyle.orientationStyle(data.orientation)
? window.OrientationStyle.orientationStyle(effectiveOrientation)
: null;
if (st) {
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
@ -2315,7 +2731,10 @@
function wallKey(c) {
if (!c) return '';
const s = c.screen_rect || {}, p = c.player_rect || {};
return `${c.wall_id}:${c.is_leader}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
// #236: rotation is part of the key. Left out, re-hanging a panel and changing only its
// rotation in the editor looks like "no change" and the panel keeps the old transform until
// it is rebooted — which reads as the editor silently ignoring you.
return `${c.wall_id}:${c.is_leader}:r${c.rotation || 0}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
}
const wallChanged = wallKey(wallConfig) !== wallKey(data.wall_config);
if (wallChanged) applyWallMode(data.wall_config || null);
@ -2571,6 +2990,10 @@
renderContent(item);
// #239: the dashboard's "3 of 7" follows the player's own advance, not just operator presses,
// so it stays honest when an item ends on its own duration.
if (PREVIEW_MODE) postPreviewState();
// Push an immediate sync so followers don't have to wait up to 1s for
// the next periodic tick before snapping to the new item.
if (wallConfig?.is_leader) emitWallSync();
@ -2868,6 +3291,12 @@
pendingWidgetSwap = null;
}
function widgetSandboxAttr(item) {
return item && item.widget_allow_same_origin
? 'allow-scripts allow-same-origin'
: 'allow-scripts';
}
// Buffered widget render (#directory-board black-cycle): build the new widget iframe
// BEHIND the current content (hidden) and reveal it only once it fires 'load' — then tear
// down the outgoing content. Kills the black flash on every widget transition, and lets a
@ -2888,7 +3317,7 @@
iframe.style.visibility = 'hidden';
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent state.
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('sandbox', widgetSandboxAttr(item));
const reveal = () => {
if (!pendingWidgetSwap || pendingWidgetSwap.iframe !== iframe) return; // superseded / discarded
@ -2914,7 +3343,7 @@
// schedule-awareness / Fix A preserved).
function reevaluateHeldWidget() {
if (nextActiveIndex(currentIndex) === currentIndex) {
advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
scheduleAdvance(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
return;
}
nextItem();
@ -2974,7 +3403,7 @@
const c = document.getElementById('playerContainer');
c.style.display = 'block';
c.appendChild(img);
advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
scheduleAdvance(nextItem, (item.duration_sec || 10) * 1000);
preloadNextImage();
}
// ---- GL transition (feat/transition-engine). Every failure path hard-cuts — never a blank. ----
@ -2985,12 +3414,38 @@
// the frame on screen now, as a texturable (CORS-clean) source: the live <img>, or — so a wipe can
// start FROM a playing clip — a snapshot canvas of the outgoing <video>'s current frame. Returns null
// if nothing on screen is texturable yet (first item after boot, un-decoded, or tainted) -> hard cut.
// Does a <video> on THIS platform actually yield pixels to a canvas?
//
// On a hardware video plane (BrightSign hwz, Tizen AVPlay) the answer is no, and the failure is
// silent: drawImage() succeeds, throws nothing, and paints a fully TRANSPARENT frame. The
// transition then runs with a blank `from` or `to` texture — a wipe from nothing, behind a video
// plane that is still lit. isMediaReadable() cannot catch it: it answers "am I ALLOWED to read
// this" (CORS), which is a different question from "did any pixels arrive".
//
// videoFrameIsCapturable() already asks the right question (a 16x16 ALPHA probe, so a genuine
// fade-to-black still reads as captured) but was only ever wired into the screenshot path.
// Cached because the answer is a property of the platform, not of the clip.
// (The cache variable itself is declared far above, in State — see the note there. It MUST NOT
// be declared here: boot renders the cached playlist from a call site above this line, and a
// `let` read before its declaration executes is a TemporalDeadZone throw, not a `null`.)
function videoCompositingAvailable(v) {
if (_videoCompositingOk !== null) return _videoCompositingOk;
if (!v || v.readyState < 2 || !v.videoWidth) return true; // undecided don't cache a guess
_videoCompositingOk = videoFrameIsCapturable(v);
if (!_videoCompositingOk) {
console.log('[transition] video frames are not readable on this platform (hardware plane) — ' +
'transitions involving video will hard-cut; image-to-image still wipes');
}
return _videoCompositingOk;
}
function currentTexturableFrame() {
const c = document.getElementById('playerContainer');
if (!c) return null;
const img = c.querySelector('img');
if (img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) return img;
const v = c.querySelector('video');
if (v && !videoCompositingAvailable(v)) return null; // hardware plane -> no from-frame -> hard cut
if (v && v.readyState >= 2 && v.videoWidth > 0 && isMediaReadable(v)) {
try {
const r = c.getBoundingClientRect();
@ -3094,7 +3549,7 @@
const dwellMs = (item.duration_sec || 10) * 1000;
const container = document.getElementById('playerContainer');
runGlWipe(fromImg, toImg, t, dwellMs,
() => { advanceTimer = setTimeout(nextItem, dwellMs); }, // onStart: arm the dwell for overlap
() => { scheduleAdvance(nextItem, dwellMs); }, // onStart: arm the dwell for overlap
() => { // mount: swap in the image, keep the armed timer
toImg.style.cssText = 'width:100%;height:100%;object-fit:contain';
container.appendChild(toImg);
@ -3124,7 +3579,9 @@
const fail = () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
console.error('Image error'); advanceTimer = setTimeout(nextItem, 3000); // skip broken item; hold prior frame
// No element to inspect here: this is the load/watchdog failure path, so nothing decoded.
// `fail` is already `done`-guarded, so it runs at most once per render.
mediaFailureSkip(null, 'image', src); // skip broken item; hold prior frame
};
// a hung load/decode must never stall the playlist: at 3s use what we have, else skip
watchdog = setTimeout(() => { if (cached) swap(cached); else fail(); }, 3000);
@ -3193,7 +3650,7 @@
video.muted = (!userHasInteracted || !!item.muted); // autoplay policy + per-item mute (#129)
if (!video.muted) video.volume = 1.0;
video.onended = () => { if (!video.loop) nextItem(); };
video.onerror = (e) => { console.error('Video error:', src, e); advanceTimer = setTimeout(nextItem, 3000); };
video.onerror = () => mediaFailureSkip(video, 'video', src);
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); }); // autoplay-policy fallback
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000); // last-resort kick
};
@ -3231,7 +3688,7 @@
video.addEventListener('error', () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
console.error('Video error:', src); advanceTimer = setTimeout(nextItem, 3000); // skip broken clip; hold prior frame
mediaFailureSkip(video, 'video(buffered)', src); // skip broken clip; hold prior frame
});
// Watchdog caps the wait so a cold/slow clip hard-cuts (mount+play) instead of the boundary hanging
// (mirrors renderImageBuffered's watchdog).
@ -3248,7 +3705,7 @@
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
// Defense in depth: a transition widget is normalized out server-side and must never render as
// content. If a stale/legacy payload still carries one, skip it instead of mounting a blank iframe.
if (item && item.widget_type === 'transition') { advanceTimer = setTimeout(nextItem, 0); return; }
if (item && item.widget_type === 'transition') { scheduleAdvance(nextItem, 0); return; }
// Fullscreen (non-wall) widget: buffered swap — never blank on reload. Runs BEFORE the
// generic teardown (which would black the screen), and owns its own refresh/advance
// timer below. Multi-zone widgets go through renderZones; wall+widget keeps the legacy
@ -3265,8 +3722,8 @@
// its duration; the first mount + every genuine transition still go through the
// buffered swap.
const held = nextActiveIndex(currentIndex) === currentIndex;
if (held) advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
else advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
if (held) scheduleAdvance(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
else scheduleAdvance(nextItem, (item.duration_sec || 30) * 1000);
}
return;
}
@ -3289,7 +3746,12 @@
&& item.mime_type.startsWith('video/') && item.mime_type !== 'video/youtube'
&& !item.widget_id && !wallConfig && !isZones && !groupSync
&& item.transition && Array.isArray(item.transition.effects) && item.transition.effects.length
&& transitionRuntimeReady();
&& transitionRuntimeReady()
// A platform whose video sits on a hardware plane cannot supply the incoming frame either:
// the warm-play snapshot comes back transparent, so the wipe would fade in from nothing.
// `null` means "not yet determined" and is treated as available — the probe needs a
// playing video, and the first one on a fresh player has not run yet.
&& _videoCompositingOk !== false;
if (isVideoBufferable) {
renderVideoBuffered(item);
return;
@ -3386,10 +3848,7 @@
// advances the index (and the tick seeks position % duration to stay aligned).
video.loop = (playlist.length === 1) || !!groupSync;
video.onended = () => { if (!video.loop && !isFollower) nextItem(); };
video.onerror = (e) => {
console.error('Video error:', src, e);
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
};
video.onerror = () => mediaFailureSkip(video, 'video', src, !isFollower);
video.onloadeddata = () => {
console.log('[wall/audio] video loaded file=' + item.filename + ' role=' + (wallConfig ? (wallConfig.is_leader ? 'leader' : 'follower') : 'solo') + ' muted=' + video.muted + ' volume=' + video.volume);
};
@ -3423,13 +3882,10 @@
img.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill'
: 'width:100%;height:100%;object-fit:contain';
img.onerror = () => {
console.error('Image error');
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
};
img.onerror = () => mediaFailureSkip(img, 'image', src, !isFollower);
mount.appendChild(img);
// Leader / single screen drives image advance; follower waits for sync
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
if (!isFollower) scheduleAdvance(nextItem, (item.duration_sec || 10) * 1000);
} else if (item.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
@ -3437,10 +3893,10 @@
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('sandbox', widgetSandboxAttr(item));
mount.appendChild(iframe);
if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(mount); // #104
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
if (!isFollower) scheduleAdvance(nextItem, (item.duration_sec || 30) * 1000);
}
}
}
@ -3549,7 +4005,7 @@
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${a.widget_rev||0}`;
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('sandbox', widgetSandboxAttr(a));
div.appendChild(iframe);
if (PREVIEW_MODE && a.widget_type === 'webpage') addWebpageNote(div); // #104
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
@ -3805,6 +4261,34 @@
// Falls through to the canvas path on ANY failure (no host, no primary storage, timeout):
// a partial screenshot showing images and widgets beats none, and the probe makes the
// video's absence explicit rather than silent.
// BrightSign's OWN capture API first: it composites the video and graphics layers, which is
// the one thing this player cannot do for itself — a canvas cannot read the hardware video
// plane, so a DOM composite comes back with the content missing. It needs no host bridge at
// all, which is what makes it work where the host route does not: page->host messaging is
// dead after load on that platform, so a request relayed through BrightScript never arrives.
if (BS && typeof BS.captureScreen === 'function') {
BS.captureScreen({ width: 960, height: 540 })
.then((dataUrl) => {
const base64 = String(dataUrl).split(',')[1];
if (base64 && base64.length > 100) {
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
console.log('[bs] native screenshot sent:', base64.length, 'chars');
} else { captureAndSendCanvas(); }
})
.catch((err) => {
console.warn('[bs] native capture unavailable (' + err.message + ') — trying the host');
hostSnapshotOrCanvas();
});
return;
}
hostSnapshotOrCanvas();
}
// The older route: ask the HOST to capture through the player's DWS. Kept as a fallback for
// firmware where the native module is absent, though on the hardware this was debugged against
// the request never reaches the host at all.
function hostSnapshotOrCanvas() {
if (BS && typeof BS.requestSnapshot === 'function' && BS.hasHost()) {
BS.requestSnapshot({ width: 960, height: 540 })
.then((dataUrl) => {
@ -3888,6 +4372,42 @@
try { return BS.displayPower(on); } catch (e) { return false; }
}
/*
* The 0..1 volume a set_volume command is asking for, or null when it does not carry one.
*
* The wire form is `payload.level`, a FRACTION. That is what the dashboard sends
* (device-detail.js: `{ level: slider / 100 }`), what a group command relays unchanged, and what
* the Android player reads (`optDouble("level")`) — so the fraction is canonical and this player
* now matches it instead of inventing a third convention.
*
* What was here read `payload.value` and treated it as a percentage, which matched nothing the
* product sends: the slider was a no-op on every browser panel. Correcting only the KEY would
* have been worse than leaving it broken — 0.5 clamped as a percentage is 0.5%, inaudible, and
* it would have looked fixed to anyone who checked only that the handler ran.
*
* `value` is still accepted as a 0..100 percentage for any caller written against the old
* handler. The scale is chosen by WHICH KEY ARRIVED, never by the magnitude of the number: 1 is
* legal in both scales (1% and full volume), so a magnitude test is guaranteed to be wrong for
* somebody, silently, in whichever direction hurts more.
*
* Extracted from the socket handler so the parsing can be asserted without a socket — the bug
* it replaces was invisible precisely because nothing ever ran it against a real payload.
*/
function volumeLevelFromCommand(data) {
if (!data || typeof data !== 'object') return null;
const p = (data.payload && typeof data.payload === 'object') ? data.payload : {};
const pick = (k) => {
const raw = (p[k] !== undefined && p[k] !== null) ? p[k] : data[k];
if (raw === undefined || raw === null || raw === '' || typeof raw === 'boolean') return null;
const n = Number(raw);
return isFinite(n) ? n : null;
};
const level = pick('level'); // canonical: 0..1
const pct = level === null ? pick('value') : null; // legacy: 0..100
const v = level !== null ? level : (pct !== null ? pct / 100 : null);
return v === null ? null : Math.max(0, Math.min(1, v));
}
// Volume that survives the next item. Media elements are created per item, so remembering the
// level is what makes a volume command stick rather than lasting until the playlist advances.
let mediaVolume = null;
@ -3896,8 +4416,17 @@
// Wall followers stay silent by design; don't override that.
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (e) { /* not a wall */ }
document.querySelectorAll('video, audio').forEach((el) => {
try { el.volume = v; el.muted = v === 0; } catch (e) { /* element torn down mid-call */ }
try { el.volume = v; } catch (e) { /* element torn down mid-call */ }
});
// Volume is a LEVEL. Mute is a separate decision with four inputs and a fixed order
// (lib/media-mute.js), already resolved when the element was mounted, and this function does
// not get to overrule it. It used to write `el.muted = (v === 0)`, which un-muted whatever was
// playing on any non-zero volume: an item an operator had deliberately silenced started making
// noise the moment somebody touched the volume slider — reproduced live (item flagged muted,
// one set_volume, muted went false). The same write contradicts the resolver's autoplay rule,
// where unmuting without a user gesture costs the VIDEO rather than winning the audio.
//
// Nothing is lost by leaving mute alone: volume 0 is silence on every media element we run on.
}
// Media elements are created per item across several code paths — fullscreen, zones, the
@ -3915,7 +4444,10 @@
}
if (mediaVolume == null) return;
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (err) { /* not a wall */ }
try { el.volume = mediaVolume; el.muted = mediaVolume === 0; } catch (err) { /* gone */ }
// Level only — see setMediaVolume: the mount path has already resolved this element's mute
// from media-mute.js, and re-deciding it here from the volume number alone would undo a
// per-item mute (and, without a user gesture, the playback itself).
try { el.volume = mediaVolume; } catch (err) { /* gone */ }
}, true);
// Screen-off state, tracked because the playlist keeps advancing while the screen is "off"
@ -4057,7 +4589,12 @@
// with. Registration succeeded there and then controlled nothing: no shell cache, no content
// cache, no offline playback, and no error to notice. The server sends
// Service-Worker-Allowed so this wider scope is permitted.
navigator.serviceWorker.register('/player/sw.js', { scope: '/' }).then(reg => {
// Registered from the ROOT, where the default scope already covers the whole origin. The
// previous form asked for a wider-than-default scope and relied on a Service-Worker-Allowed
// header reaching the browser — which Cloudflare withheld from a cached response across a
// deploy, so registration failed and the player ran with no worker at all. A rejected
// registration is worse than a narrow one, and nothing about it is visible from here.
navigator.serviceWorker.register('/sw.js').then(reg => {
console.log('Service Worker registered');
// When a new SW activates, reload so the fresh code takes effect immediately
reg.addEventListener('updatefound', () => {
@ -4071,7 +4608,20 @@
});
}
});
}, (err) => console.warn('SW registration failed:', err));
}, (err) => {
// A registration that fails has to be VISIBLE. This one went to console.warn on a display
// nobody has a console for, so a panel that could not cache anything looked identical to
// one that could — for as long as nobody thought to compare nginx logs against the
// capability it was advertising.
swRegistrationFailed = true;
console.warn('SW registration failed:', err);
try {
// 'app_error' rather than a new type: the server allow-lists event types, and a type it
// does not know is dropped silently — which would have made this report as invisible as
// the console.warn it replaces.
emitDeviceEvent('app_error', 'sw_unavailable', String((err && err.message) || err).slice(0, 200));
} catch (e) { /* reporting must never break the player */ }
});
}
// ==================== Keyboard shortcuts ====================

View file

@ -1,3 +1,9 @@
// v24: an empty playlist payload no longer prunes. `assignments: []` is what the server sends for a
// device between playlists AND for a snapshot that failed to parse, and treating it as "keep
// nothing" wiped the panel's entire offline library on a message that means nothing of the sort.
// v23: offline.cache is claimed only when a worker is actually IN CONTROL — a real BrightSign
// widget exposes navigator.serviceWorker, refuses to register one, and was advertising the
// capability to the fleet regardless.
// v22: worker scope widened to '/' (it never controlled /player before) + prune-to-playlist, so a
// replaced asset's superseded copy is reclaimed rather than waiting on the quota.
// v21: chunked resumable content prefetch + revision-keyed media URLs — index.html gained
@ -8,7 +14,7 @@
// — a player then ran a new index.html against a stale st-bridge.js and threw on every heartbeat.
// Bump whenever a shipped /player asset changes shape; content lives in its own cache, so this
// costs a small re-download and never re-fetches the playlist.
const CACHE_NAME = 'rd-player-v22';
const CACHE_NAME = 'rd-player-v24';
// Content lives in its own cache so the shell can be re-versioned (the activate handler deletes
// every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still
// perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist.
@ -48,11 +54,26 @@ self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change
// without the rev changing. This is what lets a widget keep rendering when the network is gone —
// previously the server sent no-store for every render, so widgets were the one thing the
// player's offline cache could never hold, and a display that lost its uplink lost them.
// ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it
// would match a different revision's entry, which is the staleness we are trying to remove.
// without the rev changing. ignoreSearch is deliberately NOT used here: the query string carries
// the rev, and ignoring it would match a different revision's entry, which is the staleness we
// are trying to remove.
//
// MEASURED LIMIT, do not read more into this branch than it delivers. The player mounts widgets in
// an iframe sandboxed to `allow-scripts` with NO allow-same-origin (index.html,
// renderWidgetBuffered), so that frame is an OPAQUE-origin client — and a service worker does not
// control opaque-origin clients. Its navigation request never reaches this handler. Driven in
// Chrome against a live server: a clock widget mounted five times over 25s and the shell cache
// held zero widget entries, while a plain fetch() of the identical URL from the controlled page
// was intercepted and stored. So this branch serves anything that reaches it — a same-origin
// fetch, a future non-sandboxed mount — and today the player's own widgets are not that.
//
// What actually keeps widgets rendering offline right now is the HTTP cache plus the server's
// `max-age=31536000, immutable` on a rev-pinned render (routes/widgets.js). That is sound in a
// desktop browser and is NOT a documented-persistent store on BrightSign, which guarantees
// survival across reboots for IndexedDB, localStorage and SQLite only — the same gap that made
// content caching necessary. Closing it properly means routing the render through a same-origin
// fetch and mounting it as srcdoc; granting the frame allow-same-origin instead would hand widget
// scripts the player's origin, which is not a trade worth making for an offline nicety.
if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) {
event.respondWith(
caches.match(event.request).then(cached => {
@ -140,7 +161,19 @@ self.addEventListener('message', (event) => {
// writes a new randomly-named file, so the old copy lives at a different PATH and nothing keyed
// on the asset path can find it. Without this the cache only grows, and on a panel with a 1GB
// widget quota a handful of replaced videos is the entire budget.
if (data.prune) prefetchChain = prefetchChain.then(() => pruneToPlaylist(data.urls)).catch(() => {});
//
// An EMPTY list is never a prune instruction, and that distinction is the whole guard. "This
// display needs nothing" and "the payload did not arrive intact" are the same message on the
// wire, and the second one is not rare: buildPlaylistPayload() yields `assignments: []` for a
// device between playlists, for a playlist that has never been published, AND for a
// published_snapshot that fails to JSON.parse. Honouring it deleted every byte of media the panel
// held — reproduced here: three cached assets, one empty payload, cache emptied — which is only
// survivable while the uplink is up, i.e. exactly when this cache does not matter. A cache that is
// kept too long costs disk the quota reclaims anyway; one dropped at the wrong moment is a dark
// screen with no way back.
if (data.prune && data.urls.length > 0) {
prefetchChain = prefetchChain.then(() => pruneToPlaylist(data.urls)).catch(() => {});
}
for (const url of data.urls) {
if (typeof url !== 'string' || !POLICY || !POLICY.isCacheableContent(url, 'GET')) continue;

View file

@ -3,6 +3,7 @@ const router = express.Router();
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const oidcProviders = require('../lib/oidc-providers');
const { canAdminWorkspace } = require('../lib/permissions');
const { requirePlatformAdmin, requireAdmin } = require('../middleware/auth');
const { logActivity, getClientIp } = require('../services/activity');
@ -20,7 +21,9 @@ const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require(
// have no user/role-management power (#13).
// Same email shape the invite-create endpoint validates against (workspaces.js).
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Markup characters are not legal here. The looser form admitted < > " ' and an admin-
// chosen email became stored XSS in the platform admin's user list.
const EMAIL_RE = /^[^\s@<>"'`\\;,()\[\]]+@[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/;
const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer'];
// Mirror the server-side minimum enforced by PUT /api/auth/me and register.
const MIN_PASSWORD_LENGTH = 8;
@ -55,6 +58,53 @@ router.post('/users', (req, res) => {
if (!canAdminWorkspace(db, req.user, ws)) {
return res.status(403).json({ error: 'Admin access required' });
}
/*
* An SSO-only organization must not have password accounts minted into it.
*
* This route creates a LOCAL account with an admin-chosen password, and it accepts any address
* so on a tenant that requires single sign-on it was a one-call backdoor: create
* `contractor@somewhere-else.test` bound to the workspace, log in with the password, and every
* control the customer turned SSO-only on for is behind you. A review did exactly that, and the
* account it created could then mint another.
*
* platform_admin keeps the ability, because that is the operator break-glass the same
* exemption the login gate makes, for the same reason.
*/
if (req.user.role !== 'platform_admin' && ws.organization_id) {
// The table is absent on a single-tenant install; that simply means no organization requires
// single sign-on, so creation proceeds.
let org = null;
try { org = db.prepare('SELECT sso_only, name FROM organizations WHERE id = ?').get(ws.organization_id); }
catch { org = null; }
if (org && org.sso_only) {
return res.status(400).json({
error: `${org.name || 'This organization'} requires single sign-on, so password accounts cannot be created. Invite the person through your identity provider instead.`,
code: 'sso_only_org',
});
}
}
/*
* And the ADDRESS's own domain, wherever it is being created.
*
* Gating only on the target workspace left the squat open through a different door: create your
* own organization, then mint `cfo@theircompany.test` into YOUR workspace. Login is refused, so
* it is not access but the row now has a password_hash, and an SSO login will not adopt a row
* that has one. The real CFO can then never sign in through their own identity provider, and a
* password reset they CAN complete lands them at a login that refuses them. Permanent, with no
* self-service way out, for any address at any SSO-only customer.
*/
if (req.user.role !== 'platform_admin') {
let ownedBy = null;
try { ownedBy = oidcProviders.ssoOnlyForEmail(email); } catch { ownedBy = { unavailable: true }; }
if (ownedBy) {
return res.status(400).json({
error: 'That email domain uses single sign-on, so a password account cannot be created for it.',
code: 'sso_only_domain',
});
}
}
// Stamp the target workspace so the activityLogger middleware (and our
// explicit audit row) attribute to the right tenant.
req.workspaceId = ws.id;
@ -376,6 +426,50 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
res.json({ enabled });
});
// ===================== Opt-in install statistics =====================
// Returns the decision state, the EXACT payload that would be sent, and what was last actually
// sent. Handing over the real payload rather than a description is the point: an operator can
// check instead of trusting a sentence, and the code is public so a mismatch would be visible.
const telemetry = require('../lib/telemetry');
router.get('/telemetry', requirePlatformAdmin, (req, res) => {
res.json({
state: telemetry.state(), // 'unasked' | 'on' | 'off'
payload: telemetry.payload(db), // what WOULD be sent, right now
endpoint: telemetry.endpoint(), // ours — the host an operator may need to allowlist
extra_endpoint: telemetry.extraEndpoint(),// their own collector, if configured
destinations: telemetry.destinations(), // everywhere it actually goes, right now
last_report: telemetry.getLastReport(), // what was actually sent, and when
last_error: telemetry.getLastError(), // why the last attempt failed, if it did
});
});
router.put('/telemetry', requirePlatformAdmin, async (req, res) => {
// Both answers are recorded. Declining must persist as 'off' rather than staying 'unasked',
// or the prompt returns after every update — which is how telemetry earns its bad name.
const enabled = !!req.body.enabled;
const state = telemetry.setEnabled(enabled);
logActivity(req.user.id, 'admin_set_telemetry', `enabled: ${enabled}`, null, getClientIp(req), null);
// Send once, now, rather than waiting for the next daily tick. Two reasons: the operator is
// standing right here and "nothing has been sent" for the next 24h reads as broken, and an
// egress-filtered network fails HERE where we can name the host to unblock — instead of
// failing silently tonight where nobody is watching.
let first = null;
if (enabled) first = await telemetry.report(db);
res.json({
state,
payload: telemetry.payload(db),
endpoint: telemetry.endpoint(),
extra_endpoint: telemetry.extraEndpoint(),
destinations: telemetry.destinations(),
first_report: first && { sent: first.sent, reason: first.reason || null },
last_report: telemetry.getLastReport(),
last_error: telemetry.getLastError(),
});
});
// ===================== Version update indicator =====================
// check-update = requireAdmin — a read-only GHCR poll, operational.
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the

View file

@ -17,6 +17,7 @@ const { listDesignatedPlaylists, isZonedPlaylist, folderSubtree } = require('../
const { listLayoutGeometry } = require('../lib/agency-layouts');
const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish
const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set
const { resolveItemDuration } = require('../lib/item-duration');
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@ -112,7 +113,9 @@ router.post('/playlists/:playlistId/items', (req, res) => {
if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
}
duration_sec = duration_sec || content.duration_sec || 10;
// #237: the raw content duration used to be stored as probed (31.7s), and a fraction is
// truncated by the Android player's optInt read — so round to whole seconds here too.
duration_sec = resolveItemDuration(duration_sec, content);
const sd = start_date ?? null, ed = end_date ?? null;
for (const [k, v] of [['start_date', sd], ['end_date', ed]]) {

View file

@ -8,21 +8,15 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// though playlists.js itself isn't yet workspace-filtered.
const { accessContext } = require('../lib/tenancy');
const { zoneInLayout } = require('../lib/zone-validate');
// #237 + #widget zero-duration loop: one place decides what duration a new item gets —
// explicit value, else the content's own length, else the 10s default (and never a 0).
const { resolveItemDuration } = require('../lib/item-duration');
// Mark playlist as draft (called after any item mutation)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Hardening (#widget zero-duration loop): a non-positive duration — especially
// duration_sec=0 on a widget — makes the player schedule a 0ms auto-advance, which
// self-loops and black-screens the TV. Never STORE a bad value: floor any missing/
// invalid/<1 duration to the 10s default so it can't reach a device.
function normalizeDuration(v) {
const n = Number(v);
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 10;
}
// Hardening (#zone-orphan): a zone_id only renders if it belongs to the layout the
// device is actually showing. Assigning a zone from a DIFFERENT layout (e.g. after a
// layout switch/duplicate) creates an item that the players can't place. We CLEAR a
@ -105,17 +99,20 @@ router.post('/device/:deviceId', (req, res) => {
const access = checkDeviceAccess(req, res, 'deviceId', true);
if (!access) return;
const { content_id, widget_id, zone_id, sort_order } = req.body;
const duration_sec = normalizeDuration(req.body.duration_sec);
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
let content = null;
if (content_id) {
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
content = db.prepare('SELECT id, workspace_id, duration_sec FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' });
if (content.workspace_id && content.workspace_id !== access.device.workspace_id) {
return res.status(403).json({ error: 'Content is not in this device\'s workspace' });
}
}
// #237: pushing a video straight at a display is the shortest path in the product, so it
// has to default to the clip's length too — not the 10s that cut it off mid-play.
const duration_sec = resolveItemDuration(req.body.duration_sec, content);
if (widget_id) {
const widget = db.prepare('SELECT id, workspace_id FROM widgets WHERE id = ?').get(widget_id);
if (!widget) return res.status(404).json({ error: 'Widget not found' });
@ -234,7 +231,7 @@ router.put('/:id', (req, res) => {
const values = [];
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(normalizeDuration(duration_sec)); }
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(resolveItemDuration(duration_sec, null)); }
// zone_id can be null (clear the zone) - treat undefined as "no change",
// any other value (including null) as "write this".
if (zone_id !== undefined) {
@ -337,7 +334,7 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
const transaction = db.transaction(() => {
sourceItems.forEach((a, i) => {
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, normalizeDuration(a.duration_sec));
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, resolveItemDuration(a.duration_sec, null));
});
});
transaction();

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2b: workspace-aware access. Mirrors the pattern from devices.js.
const { accessContext } = require('../lib/tenancy');
// #73: the upload ingest (processing + insert) is now shared with the agency router.
const { ingestUploadedFile } = require('../lib/content-ingest');
const { ingestUploadedFile, deriveMediaMetadata } = require('../lib/content-ingest');
const { finalizeUpload, INLINE_SAFE_EXTS } = require('../lib/upload-sniff');
// Multer captures file.originalname directly from the multipart filename header,
@ -511,24 +511,18 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
let filepath, mime;
try { ({ filepath, mime } = finalizeUpload(req.file)); }
catch (e) { return res.status(e.status || 400).json({ error: e.message }); }
let width = null, height = null, thumbnailPath = null;
// Generate new thumbnail for images (SVG skipped — see lib/content-ingest.js)
try {
if (mime === 'image/svg+xml') {
thumbnailPath = filepath;
} else if (mime.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(req.file.path).metadata();
width = metadata.width;
height = metadata.height;
thumbnailPath = `thumb_${filepath}`;
await sharp(req.file.path).resize(config.thumbnailWidth).jpeg({ quality: 70 })
.toFile(path.join(config.contentDir, thumbnailPath));
}
} catch (e) {
console.warn('Thumbnail generation failed:', e.message);
}
// Re-derive EVERYTHING the bytes decide, through the SAME function the upload path uses.
// This route used to carry a shorter copy that handled images only, and got three things
// wrong that an upload gets right:
// - a replaced VIDEO lost its duration (the row kept the OLD clip's length, so #237's
// "default an item to the clip's own length" then handed out the wrong number), its
// dimensions, and its thumbnail;
// - a replaced IMAGE was measured with raw sharp metadata instead of imageDisplayDims and
// thumbnailed without .rotate(), re-introducing the EXIF-orientation bug (#170) that
// ingest fixes — a portrait photo came back landscape with blue bars;
// - both left width/height NULL for video, which is what the orientation-aware paths read.
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(req.file.path, filepath, mime);
// Bump the revision: this is the ONLY operation in the product that changes an asset's bytes
// without changing its id, so it is the only thing that can make a player's cached copy wrong.
@ -537,11 +531,15 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
// strftime seconds can collide with the previous value if a replace lands inside the same second
// as the upload (a small file, a scripted replace) — and a revision that does not change is a
// cache that never updates. MAX(now, previous + 1) guarantees it moves.
// duration_sec comes from the NEW bytes. COALESCE-to-NULL rather than keeping the old value:
// a replace that turns a video into an image genuinely has no duration, and a stale one would
// silently become the default for every later playlist add (lib/item-duration.js).
db.prepare(`UPDATE content
SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ?,
duration_sec = ?,
updated_at = MAX(CAST(strftime('%s','now') AS INTEGER), COALESCE(NULLIF(updated_at, 0), created_at) + 1)
WHERE id = ?`)
.run(filepath, mime, req.file.size, thumbnailPath, width, height, req.params.id);
.run(filepath, mime, req.file.size, thumbnailPath, width, height, durationSec, req.params.id);
// ...and tell the panels, which the old code did not. Without this the new bytes reached a screen
// only when something else happened to trigger a playlist refresh — an operator replacing a video

Some files were not shown because too many files have changed in this diff Show more