Compare commits

...

310 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
ScreenTinker ba45c2d60c chore(release): v1.9.29-rc4
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-05 16:32:56 -05:00
ScreenTinker 75c1940821 Fix the worker scope that made web offline playback silently inert, and prune superseded assets
Found by QA against a real browser, not by any test in the suite: the bug lived
entirely in the relationship between a URL and a header.

A service worker's default scope is its own directory, so /player/sw.js could
only ever control /player/ and below — which does not include /player itself.
The player is served at all three of /player, /player/ and /player/index.html,
and /player is the one that gets used: it is what the dashboard shows and what
gets typed into a panel. On that URL registration SUCCEEDED, logged "Service
Worker registered", and then controlled nothing. No shell cache, no content
cache, no offline playback, no error. Every web and BrightSign panel served at
/player has been running with its offline story switched off.

Registration now asks for scope '/' and the server sends Service-Worker-Allowed
to permit it. Both halves are required — without the header the registration
does not narrow, it fails outright.

Also: revision-keyed sweeping could not reclaim a replaced asset's predecessor.
A replace writes a NEW randomly-named file, so the superseded copy lives at a
different path entirely and nothing keyed on the asset path can find it; it
would sit there until the quota evicted it. The player now declares the complete
set of media it needs — the raw assignments, so multi-zone items are included
and a prune cannot delete something a zone is still playing — and the worker
drops everything else.

QA results this pass: web player 18/18 against a real browser (cold start with
no network renders a cached video at readyState 4); Android 12/12 on a device
including a replace round-trip that re-fetched 6MB and then dropped it for the
new bytes, and a cold start with the server stopped that played from disk;
Tizen 11/11 for the no-storage path, which must degrade to streaming and must
not claim a capability it cannot honour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 16:30:12 -05:00
ScreenTinker 684e60fc55 Offline media on every player, and a revision so the cache can still be updated
Two halves of the same problem. A screen has to keep playing when the link is
gone, and it must not keep playing the wrong thing once the link is back.

CACHING FOR OFFLINE, on the players that could not:

- Tizen cached nothing but the playlist, so a panel came back from a reboot
  knowing exactly what to show and fetched every frame of it from a server that
  was not there. tizen/js/media-cache.js caches the media itself to wgt-private
  (the store Tizen documents as surviving reboots), resumable via Range and
  If-Range, with the transfer async so a stalled chunk cannot freeze the player.
  offline.cache moves from "absent" to a runtime claim: a build with no writable
  private storage still says nothing.

- The web player's worker stored only what a single fetch() happened to
  complete, which on a marginal link is nothing at all — a 200MB asset never
  finishes in one go and every retry starts from zero. It now accumulates in
  resumable chunks, driven by the player's playlist rather than by playback, so
  the prefetch is not competing with the video that is currently on screen for
  the same scarce bandwidth. BrightSign inherits this.

STILL UPDATING, which caching quietly breaks:

PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every
cache keys on that id, so before this the new bytes could not reach a panel that
already held the old ones — not until the next refresh, but never. Content now
carries a revision, stamped onto each item at send time like widget revs, and
every player keys its cache on it. The same send-time refresh fixes a second
bug: a replace writes a new randomly-named file and unlinks the old one, so the
filepath in a published snapshot pointed at a deleted file and web panels 404'd
on the item until somebody republished the playlist. The route now also pushes
to affected devices, which it never did.

Bytes are kept only where they can be built upon: no validator means no safe
resume, so the partial is discarded and the attempt backs off as the failure it
is rather than re-fetching the same prefix forever.

Server needed no new transfer support — res.sendFile already does Range,
If-Range and 416. The Tizen cache and the service worker are both driven in Node
against fakes, because neither can be exercised without hardware and "the chunks
assemble correctly" is not something to discover from a panel showing a corrupt
video.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 15:27:36 -05:00
ScreenTinker cf3b2e62af Resume interrupted content downloads instead of restarting from zero
A site on a marginal link (the report came from a one-bar 5G install) could
never fill its cache. Every attempt started at byte 0 and the .part was deleted
on any interruption, so an asset larger than one call's worth of transfer was
discarded and re-fetched forever — five minutes of progress thrown away, back
off, five more minutes, thrown away. With nothing cached, the player showed the
waiting state, which is what got reported as "the screens go black instead of
playing cached content". The offline playback path was never the problem; the
cache simply could not be filled.

An interrupted download now keeps its .part and the next attempt asks for the
rest with Range. Two ways that could corrupt the cache, both closed: If-Range
with a stored validator makes a changed asset come back as a full 200 (restart)
rather than a spliceable tail, and a partial longer than the asset gets a 416
and is discarded. Bytes are kept only when they can be built upon — with no
validator there is no safe resume, so the partial is dropped and the attempt
backs off as the failure it is, rather than re-fetching the same prefix forever.

DownloadCoordinator now distinguishes progress from failure: attempts chain
while bytes are landing (bounded, single-flight held throughout) and only a
no-progress attempt escalates the exponential backoff or acks "failed" — an
advancing download is not a failed one and should not be shown as such.

Server side is unchanged; res.sendFile already serves Range/If-Range, and
content-range-resume.test.js pins that since it is load-bearing and a future
middleware could silently remove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:54:09 -05:00
ScreenTinker 3e6c97ba10 Merge: web player capability declaration and the cross-player parity matrix
# Conflicts:
#	server/ws/deviceSocket.js
2026-08-05 14:36:28 -05:00
ScreenTinker 4153448c55 Merge: capability persistence, dashboard gating, server-side refusal 2026-08-05 14:29:09 -05:00
ScreenTinker 6310f48590 Merge: platform-native capability declaration 2026-08-05 14:25:46 -05:00
ScreenTinker f8e359895d Merge: platform-native capability declaration 2026-08-05 14:25:46 -05:00
ScreenTinker 90eee4ce45 Merge: platform-native capability declaration 2026-08-05 14:25:46 -05:00
ScreenTinker 0082191f9b Show only the controls a display can actually honour
Every device control was offered to every display. A browser tab was shown
"Reboot device", a Tizen TV was shown screen power, a player with no
framebuffer read was shown a live view that stayed black. They all looked
like working buttons and did nothing — the "reports success and changes
nothing" shape that keeps costing people days.

Players now declare what they can do at registration, because only the
player knows at runtime: an Android panel gains real screenshots when
accessibility is switched on and loses Tier-2 when device owner is revoked.
The dashboard hides what is not supported rather than disabling it, and the
Info tab lists the capability set so a missing control is explainable.

The declaration is three-state and the middle state is load bearing: NULL
means "has never told us anything" and falls back to a per-platform
baseline, because several hundred displays in the field will not update
before this deploys and blanking their controls would be a far worse bug.
An empty array means "I genuinely can do nothing" and is honoured.

Hiding a button is not enforcement, so unsupported commands are also
refused server-side — the socket is reachable directly and a stale tab
still renders the old controls. Group sends report skipped devices
separately from sent ones; counting an unreachable member as "sent" is how
an operator walks away believing the whole group rebooted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:24:52 -05:00
ScreenTinker e5583e529e The Tizen baseline describes a fielded panel, not the one we are shipping
Two more corrections from the cross-player audit, both mine.

audio.volume removed: a fielded Tizen panel has NO set_volume handler — the
command falls through to "unknown command" and the dashboard slider does
nothing. One of the platform branches adds a handler, and those panels will
declare the capability for themselves once they run it; the baseline exists to
describe an un-updated display, so it must not borrow credit from a build that
has not shipped.

remote.screenshot and remote.stream added: both really are implemented in the
shipped player (captureAndSend, startStreaming). Omitting them would have hidden
working controls on every legacy Tizen display the moment gating went live —
the opposite failure, and the more damaging one.

That asymmetry is the thing to hold on to: over-claiming shows a dead button,
under-claiming removes a working one, and only reading the shipped code tells
you which you are doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:21:19 -05:00
ScreenTinker c4ee7d008f web player: declare capabilities at runtime, persist them, and audit all four players
The dashboard offered every control to every display, so a browser tab showed a
reboot button that could never work. server/lib/player-capabilities.js defines the
vocabulary; this makes the web player actually speak it.

The declaration is computed, not constant, because the same index.html is BOTH the
browser player and the BrightSign player. system.reboot / display.power /
display.resolution / system.self_update are claimed only when BS.hasHost() answers —
deliberately hasHost() and not isBrightSign(), since the UA check is also true for a
widget built without node integration, which can reach none of them. Screenshots,
offline cache, transitions and native sync are each probed the same way.

Capabilities were never persisted: the column and the handler did not exist, so a
declaration would have been sent and silently dropped. Added the migration and
applyCapabilities(). An ABSENT declaration leaves the column NULL so the baseline
still applies — several hundred fielded displays declare nothing and would otherwise
lose every control at once — while an EMPTY declaration is stored as '[]' and honoured.

docs/player-parity.md records every capability against all four players with a reason
for each "no", and flags three Tizen baseline errors found while verifying it.

Tests: 1109/1109. Both inline <script> blocks in index.html parse clean.
2026-08-05 14:17:40 -05:00
ScreenTinker a08e6c3e06 Tizen does not have offline caching — correct the baseline
The platform audit caught my own contract lying. I gave the Tizen baseline
offline.cache; Tizen caches only the playlist JSON (st_payload_cache, in
localStorage) and has no service worker and no media cache, so the bytes still
come from the network and an outage leaves a panel holding a playlist it cannot
play.

That is exactly the claim this model exists to prevent, made by the model itself,
and it would have applied to every legacy Tizen panel — the ones that declare
nothing and depend entirely on the baseline being honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:15:34 -05:00
ScreenTinker 5fe55307ba brightsign: declare capabilities from real hardware state
The dashboard offered every control to every display. This makes the
BrightSign player answer for itself, at runtime, rather than from a
per-platform table.

The table cannot work here: the same XT245 supports remote screenshots
with an SSD fitted and not without, because the DWS snapshot endpoint
writes the full-size capture to disk before returning a thumbnail and
answers "No primary storage found" on a flash-booted unit. So the bridge
asks the host.

- autorun.brs gains StorageProbe()/SendProbeResult(): walks SSD:, SD:,
  USB1: via roStorageHotplug.GetStorageStatus().mounted and reads real
  capacity through roStorageInfo. FLASH: is excluded deliberately — it is
  where the player boots from, not a volume the DWS accepts. Neither API
  has a JS equivalent, which is why this has to cross the bridge.

- st-bridge.js posts the probe during boot and folds the answer into the
  existing readiness gate, with its own 3s timeout so a widget built
  without nodejs_enabled still becomes ready. computeCapabilities() then
  gates remote.screenshot/remote.stream/system.self_update on a mounted
  volume, the lifecycle and display commands on a live host, sync.native
  on the module AND OS >= 8.2.10, and display.power on CEC module
  presence.

  Unknown is treated as NO throughout: an unanswered probe declares
  nothing storage-gated. A control that appears once a disk is fitted is
  a smaller problem than one that silently fails.

  Never declared: kiosk, brightness, screen_timeout, install_apk, shell
  (no BrightSign equivalent) and time (BrightScript can, this host does
  not implement it — the same lie in the other direction).

- Telemetry now reports the real drive from the probe instead of the
  widget's storage_quota, which it had been presenting as if it were the
  disk.

Two declarations are knowingly optimistic and documented as such:
transitions/pip composite DOM over a hardware plane and may be invisible
over video (the roVideoMode.SetGraphicsZOrder("front") fix wants a
hardware experiment, not a guess), and display.power rides module
presence on a unit whose kernel logs "failed to get cec clock". Neither
is load-bearing — transitions degrade to a hard cut, blanking works by
tearing the media down.

Tests cover the storage split, the hostless case, the sync floor, the
never-declared set, and that every declared string is in the server's
vocabulary — a typo there would silently disable a control fleet-wide.
2026-08-05 14:12:44 -05:00
ScreenTinker c07a56b47d Tizen declares what it can do, and volume and blanking now work
The dashboard offered every control to every display, so on a Tizen panel the volume
slider and screen_off did nothing and read as bugs. Two of them were genuinely dead:

  - set_volume fell through STDeviceControl.run()'s default case and was answered
    "unknown command". It is not a Samsung fleet action and must work on every build,
    so it is handled in app.js instead: tizen.tvaudiocontrol where the TV profile
    provides it (that is the TV's own volume, the only thing that reaches AVPlay video
    on the hardware plane), otherwise the media elements. The level is remembered and
    re-applied on 'play' — media elements are created per item, so a one-shot set
    lasted only until the playlist advanced.

  - screen_off was a z-index overlay, which covers the web layer only. Portrait and
    flipped video runs through AVPlay on a separate hardware plane the DOM cannot draw
    over, so the overlay went up and the video played straight through it. It now tears
    the AVPlay session down as well; screen_on re-mounts via playCurrent(), because a
    torn-down session cannot be resumed and gotoIndex() early-returns on an unchanged
    index.

js/capabilities.js declares the rest at runtime rather than from a static table,
because on Tizen the answer varies by build: reboot exists only through the B2B
surface injected on a partner-signed .wgt, and tizen.tvaudiocontrol is absent in a
browser context. Against the server baseline this adds display.power, remote.screenshot
and remote.stream (all backed by real handlers) and drops offline.cache — the payload
is cached, but media bytes are still fetched from the network, so content does not
survive an outage and claiming it would overstate.

Adds the tv.audio privilege; without it tvaudiocontrol throws SecurityError.
2026-08-05 14:11:48 -05:00
ScreenTinker 812e89f28f Android declares what it can actually do, and can wake a panel it slept
Two halves of platform-native parity.

THE DECLARATION. The player now sends a `capabilities` array on every register,
using the vocabulary in server/lib/player-capabilities.js so the dashboard can
stop offering controls that cannot work on a given panel.

Computed at registration, never cached, because almost everything interesting is
runtime state an APK cannot know about itself: accessibility gets switched on
months after install, device owner arrives through a provisioning flow, and
WRITE_SETTINGS is a grant an operator can revoke. A value captured once would be
wrong on the same hardware from one boot to the next.

The rule when uncertain is to UNDER-claim. A missing control is a support
question; a control that looks like it works and does nothing is a bug report,
and on a panel nobody can reach it is an expensive one. So:

  system.reboot / kiosk / time   owner only. Off-owner, reboot degrades to an
                                 accessibility power DIALOG and kiosk to screen
                                 pinning — both need someone at the screen, which
                                 is not a remote capability.
  system.install_apk             owner or a delegated install scope.
  system.brightness / timeout    WRITE_SETTINGS or owner. Per-window dimming
                                 works at any tier but is not what an operator
                                 means by "brightness".
  remote.screenshot / stream     accessibility only. Without it capture falls
                                 back to the app's own view.
  display.power                  see below.
  system.shell                   ALWAYS. It is app-UID `sh -c` and runs at any
                                 tier; the directive grouped it with Tier-2, but
                                 the code is not owner-gated and under-claiming
                                 would hide a working diagnostic.

Never declared, so the dashboard stops offering them: display.resolution (needs
system/root — an app cannot change the negotiated output mode) and sync.native
(frame-accurate hardware sync is a BrightSign SyncManager feature; Android has
the clock-derived group sync, which IS declared).

THE WAKE PATH. display.power was asymmetric: screen_off worked via owner, admin
FORCE_LOCK or accessibility, while screen_on was a logged no-op. The retired
attempt was `input keyevent 224`, which exec denies to an app UID, and that one
failure had been read as "no wake path exists". A wake LOCK is a different
mechanism needing only WAKE_LOCK — a normal permission already in the manifest.

That asymmetry is expensive on a fleet: an operator sleeps a panel overnight and
cannot wake it remotely, so someone drives to the site. Losing the screen is the
wrong direction to fail in. Handled in the service as well as the Activity, since
the service is the only thing guaranteed alive, and paired with a keyguard
dismiss because waking to a lock screen is half a fix. The lock is held briefly
and self-expires, so a missed release cannot pin a panel on.

display.power is therefore declared on the OFF path (owner/admin/accessibility),
which is now the binding constraint — offering a control that sleeps a panel it
cannot wake would be the worst version of this feature.

DeviceInfo.isAccessibilityEnabled is internal rather than private so the
declaration asks the same question as the telemetry shown beside it, instead of
a second copy that drifts.

Verified: APK compiles (9,023,669 bytes); all 25 declared strings are known to
the server vocabulary, with zero unknown; and they survive R8 into classes4.dex
along with the `capabilities` payload key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:08:52 -05:00
ScreenTinker 6bc709d2f7 The capability contract: what each player can actually do
Foundation for platform-native parity. The dashboard offered every control to
every display — a browser tab cannot reboot its host, a Tizen TV has no
device-owner concept, a BrightSign has no per-window brightness — so those
buttons did nothing, silently, and read as bugs. "UI that reports success and
changes nothing" is a recurring shape here; this ends it by letting the frontend
hide what a display cannot do.

The player DECLARES its capabilities at registration rather than the server
inferring them from a table, because only the player knows at runtime: an Android
device gains real screenshots when accessibility is switched on and loses Tier-2
commands when it is not device owner.

The trap this had to avoid is the opposite failure. Several hundred displays are
in the field declaring nothing, and none will update before the next dashboard
deploy — treating absence as "supports nothing" would strip the UI for the entire
fleet at once. So an ABSENT declaration falls back to a per-platform baseline,
while an EMPTY one is honoured as a player genuinely saying it can do nothing.
Those two cases are trivial to conflate and the difference is a dark dashboard.

Baselines carry only what has always worked on that platform. Anything
conditional — screenshots needing accessibility, kiosk needing device owner,
native sync needing one L2 network — is omitted, so a legacy display shows those
controls only once it declares them. A control that appears late beats one that
lies now.

Capability names are stable strings because they are persisted per device and
sent over the wire; renaming one silently disables a control on every display
still reporting the old name. An unknown name from a NEWER player is dropped
rather than invalidating the whole declaration.

1094 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:59:04 -05:00
ScreenTinker d205a49dfa The settings PIN can be rotated and set from the dashboard
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.

POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.

Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.

Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.

Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.

1084 pass; Android compiles.

Asked for by chris@chris-pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:44:15 -05:00
ScreenTinker b419830629 Android: the boot notice now clears, and kiosk survives a reboot
Two field reports from a customer running the player on Android x86.

THE "STARTING DISPLAY…" BANNER NEVER CLEARED. Relauncher launches the activity
directly when the overlay permission is granted — the normal kiosk setup — and
THEN posts the notification, deliberately, so a device that could not auto-launch
still has a tappable way back. On a device where the launch DID work, that
ordering posts the prompt after onCreate has already cancelled it, and nothing
cancels it again: a permanent banner over content that is already playing. They
sent a photo of exactly that.

Cancelling in onCreate only ever closed half the race. It now also clears on
every foreground: if the player is on screen, a "Starting display…" prompt is
stale by definition, whoever posted it and whenever.

KIOSK MODE DID NOT SURVIVE A REBOOT. startLockTask() is a runtime call on the
Activity, and nothing persisted the operator's intent — so a locked panel came
back up unlocked, silently, and the only symptom is that someone can suddenly
leave the app. The flag is now written BEFORE the lock is attempted, so a device
that reboots mid-call still comes back in the state that was asked for, and a
lock that fails is retried on the next start rather than forgotten. Restored in
onStart rather than onCreate because lock-task can be dropped on some
transitions.

Also theirs: an "Exit kiosk mode" entry in the PIN menu, shown ONLY when locked.
With kiosk on and no other input, that menu is the only way out of a panel, and
a menu entry that does nothing is worse than no entry.

Builds clean: versionCode 100, v1 JAR signature intact.

Reported by chris@chris-pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:30:48 -05:00
ScreenTinker 803f4ec26d Portrait templates, a canvas that matches the layout, and a playlist mockup
Three related pieces. Zones were already stored as percentages and layouts
already carried their own width/height, so this is mostly design work rather
than plumbing.

SIX PORTRAIT TEMPLATES at 1080x1920. Deliberately not the landscape set turned
sideways: "Three Column" at 33% each becomes three tall slivers, and a 15% ticker
that reads well across 1080px is a 288px band on a 1920px-tall panel, so the
portrait ticker is 12% and the PiP window is wider than tall (a 30x30 box is
square on 16:9 and 324x576 in portrait). Seeded in schema.sql for fresh installs
AND as a migration, because schema.sql never runs on an existing database — and
upgraded instances are exactly the ones with portrait panels already deployed.

THE EDITOR CANVAS followed a hardcoded padding-top:56.25% — the 16:9 ratio trick.
Authoring a portrait layout meant dragging zones on a landscape canvas: the
percentages landed correctly on the panel and looked wrong everywhere you
designed them. It now derives from the layout's own height/width, clamped so a
pathological row cannot produce an unusable editor.

THE PLAYLIST PAGE now draws where content actually lands. A playlist has no
intrinsic layout, so the server reuses #104's derivation from the items' own zone
bindings and returns it. Previously an item could be tagged "Bottom Ticker" with
nothing to say the ticker is a thin strip along the bottom — people assigned by
zone name and found out by looking at a screen. Empty zones are dimmed, because
an empty zone shows its background colour on a real panel and that is worth
seeing before publishing rather than after.

Verified against a copy of prod: 6 templates and 12 zones created, the 7
landscape templates untouched, no errors at boot, and a second boot changes
nothing. Each stacked template's zone heights sum to exactly 100%.

1074 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:23:15 -05:00
ScreenTinker 604c390a55 Portrait on the web player was 420px off-screen — rotating a box does not move it
Reported as "rotation doesn't work correctly". It is a geometry bug, not a
rendering one, which is why it reads as mysterious.

#playerContainer is pinned `inset: 0`. Rotation set width:100vh, height:100vw and
rotate(90deg) — leaving the box in the TOP-LEFT corner and spinning it about its
own centre rather than the viewport's. On a 1920x1080 panel the content landed at
x -420..1500, y 420..1500 against a viewport of 0..1920, 0..1080: correctly
rotated, wrongly placed, cropped on two edges.

Tizen already did this correctly — top/left 50% plus translate(-50%,-50%) — and
Android does the equivalent with translationX/Y of (w-h)/2. The web player was
the odd one out, and BrightSign inherited it on top of its own hardware-plane
problem.

The rule now lives in server/lib/orientation-style.js, served to the player from
its single source, with the arithmetic pinned by tests that compute where the
rotated box actually lands on 16:9 and 5:4 panels. Three things those tests hold
that are easy to get wrong: the translate must come BEFORE the rotate (transforms
apply right-to-left, so reversing them rotates the correction too), 180 must NOT
swap dimensions (the box already fits; swapping letterboxes it), and landscape
must clear EVERY property the rotated state set (a half-reset leaves the
container stuck at 100vh wide, so rotation appears to persist after switching
back).

1074 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:58:50 -05:00
ScreenTinker 1c7d5f1359 Rotation on BrightSign must rotate the output, not the DOM
Audited rotation across all four players after reports it misbehaves.

  Android    native rootView.rotation + layout swap — the ExoPlayer surface is
             inside the rotated view, so video turns with it.
  Tizen      CSS for graphics AND AVPlay hardware-plane rotation for video. The
             code says why: a CSS-rotated <video> "blacks out" on Tizen.
  Web        CSS transform. Correct — a browser composites video in the DOM.
  BrightSign CSS transform only, inherited from the web player. BROKEN: with hwz
             enabled the video decodes onto a hardware plane the DOM cannot
             transform, so the images and widgets rotate and the video does not.
             A portrait panel plays sideways video.

BrightSign is the platform that does not rotate correctly, and Tizen had already
found the same wall from the other side — any platform compositing video below
the DOM needs rotation done at the output.

roVideoMode takes a transform (normal/90/180/270) and rotating the screen rotates
EVERY layer, because it happens below the compositor rather than above it. The
player now asks the host first and, when the host succeeds, clears its own CSS
transform — otherwise the graphics rotate twice while the video rotates once.

The host reports success rather than assuming it: if it cannot rotate, the CSS
path stands, which turns most of the content instead of none of it, and the
promise resolves false rather than never settling. A portrait panel showing
landscape content with no clue why is the outcome worth avoiding.

1066 pass. The BrightScript needs hardware to verify; the decision path does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:49:26 -05:00
ScreenTinker df3a2879fa Remote screenshots use the framebuffer, and an opted-in tester can move forward
Two things reviewed against the hardware.

REMOTE CAPTURE. An in-page canvas cannot read the hardware plane, so a
screenshot from a BrightSign is a composite with the video missing. The player
now asks the HOST, which uses the unit's own Diagnostic Web Server to capture the
real framebuffer, video included.

It has to run in BrightScript rather than the page for two reasons: the DWS is
http on localhost while the player is served over https, so the page would be
blocked as mixed content; and BrightScript is subject to neither CORS nor
mixed-content rules. Credentials are the documented default — user "admin",
password = the unit serial — which the host reads directly.

It requires PRIMARY STORAGE: the endpoint writes the full-size capture to disk
before returning a thumbnail, so a unit with no card or SSD answers "No primary
storage found." That message is passed through verbatim rather than swallowed,
and the canvas path still runs as a fallback, so a player with no disk keeps
producing the partial screenshot it can rather than nothing at all. Verified
against the real unit: the endpoint is reachable and blocked solely on storage.

THE STUCK TESTER. An opted-in player on 1.9.29-rc1 was told "holding prerelease
of the same core" when offered rc3 — so it would never move forward through
rc1 -> rc2 -> rc3, which is the opposite of what opting in is for, and would have
stopped our own XT245 ever receiving the next candidate.

The hold rule exists to stop a test build being dragged BACK to its release. It
now applies only when the advertised version IS that release: a newer prerelease
of the same core is offered normally, the release still cannot claw a tester
back, a newer core still lands, and a player that never opted in is still refused
a prerelease.

Also verified end to end on alpha: the advertised sha256 matches the served bytes
exactly, size matches, and every member of the package is stored.

1063 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:33:34 -05:00
ScreenTinker 7233466030 A stale bridge must not kill the heartbeat, and rc3 must invalidate the shell
Caught on hardware immediately after deploying rc3 to alpha: the player kept
playing content while reporting nothing at all, throwing every 15 seconds.

    Uncaught TypeError: BS.telemetrySnapshot is not a function

The page was rc3 and the bridge it ran was older. Two causes, both fixed.

CACHE_NAME stayed at rd-player-v19 across a release that changed both the
service worker's fetch strategy and the shipped /player assets. The activate
handler deletes every cache whose name does not match, so keeping the name kept
the previous shell cache alive — including a stale st-bridge.js. Bumped to v20.
Content lives in its own cache, so this costs a small shell re-download and never
re-fetches a playlist.

The deeper defect is that the call site treated an optional bridge method as
guaranteed. It was the ONLY unguarded BS.* call in the player; every other one
checks or wraps. The bridge and the page are halves of one contract but are
fetched separately, so version skew is a normal condition, not an anomaly — it
must degrade, not throw. Now guarded on typeof, so a skewed pair reports the
fields it can and keeps heartbeating.

Worth naming the failure shape: the display looked perfectly healthy. Content
played, the socket connected, the device showed online — and telemetry silently
stopped. Anything that reports health through the same path it is breaking will
fail this way.

1056 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:18:44 -05:00
ScreenTinker 3059d8cd5c Fix the stored-archive check: unzip's totals row is not an entry
The rc3 release failed on a correctly-built archive. `unzip -v` ends with a
TOTALS row whose first field is also numeric, so "numeric $1" matched it and the
check read the byte count as a compression method — reporting a fully stored
archive as compressed.

The same noise appeared in my local negative control as a phantom third entry and
I read past it, which is why this reached CI. The method column must look like a
method for the row to be an entry at all.

Verified in both directions: a stored archive passes, a deflated one flags every
member and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 11:46:06 -05:00
ScreenTinker c170861124 chore(release): v1.9.29-rc3 2026-08-05 11:37:53 -05:00
ScreenTinker de161bf43a Changelog for 1.9.29-rc3 2026-08-05 11:37:52 -05:00
ScreenTinker 30a71c1319 autorun.zip must be STORED and opened with roBrightPackage
A BrightSign consultant ran our v1.9.29-rc2 autorun.zip through BSN.cloud's
automated deployment. The archive reached the player and then could not be
opened — reported as invalid. Two causes, both ours.

1. COMPRESSION. We built with default deflate. The player bootstrap extracts
   autozip.brs by itself before any script runs, and roBrightPackage supports a
   specific set of methods, of which "no compression" is the universally safe
   one. Both builders now store: scripts/build-autorun-zip.sh passes -0, and the
   server-side package builder used archiver level 9 — maximum deflate — so
   EVERY self-update package it produced would have failed the same way, silently
   and in the field.

2. THE UNPACK API. We used roUnzip; BrightSign's own tooling uses
   roBrightPackage. Converted in autozip.brs and in the self-update path.

This is the failure mode worth naming: a compressed package uploads, downloads
and deploys perfectly, then fails to open on the player. It reads as a broken
deployment rather than a broken zip, so it gets debugged everywhere except where
the bug is. Both builders now ASSERT the property rather than trusting the flag —
the build script walks `unzip -v` and refuses a compressed member, and a test
walks the local file headers of the server-built package checking method 0.
Verified by negative control: re-enabling compression fails the test.

Also adopted the shipped volume-discovery pattern in autozip.brs — probe
USB1:/SD:/SSD:/FLASH: for the archive instead of guessing two volumes. The unit
that drove this port boots from FLASH because its card interface is dead, and
extracting to a volume that does not exist fails silently.

1056 pass.

Reported by giyokun, who was right about both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 11:36:18 -05:00
ScreenTinker cf1124d687 Mute reaches YouTube items — it never did, and failed opposite ways per player
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
Muting was implemented three times and agreed nowhere. A YouTube item is a
cross-origin iframe, so `el.muted` reaches nothing; only the IFrame API can
touch it. Both browser-family players got this wrong, in opposite directions:

  web    playerVars.mute was `userHasInteracted ? 0 : 1` — autoplay policy and
         NOTHING else. An item an operator muted in the admin console played
         WITH SOUND, a wall follower blared alongside its leader, and the
         real-time device:mute-changed toggle only ever touched `<video>`.
         onReady then unmuted unconditionally, and the click-to-unmute overlay
         appeared on deliberately-muted items and undid the operator's setting.

  tizen  the embed URL hardcoded `mute=1`, so YouTube there was PERMANENTLY
         silent: the per-item flag was never read and nothing could unmute it.
         device:mute-changed did nothing at all, because it dereferenced a
         <video> that is null for a YouTube item.

Android was already correct and is unchanged — it is the reference here.

The rule now lives once, in server/lib/media-mute.js, served to the web player
from its single source the same way schedule-eval.js is, and mirrored in Tizen
(which ships inside the .wgt and cannot import it). The ORDER is the substance:
a wall follower is always silent (one wall, one audio source) > autoplay policy,
which is a hard constraint rather than a preference because unmuted playback
without a gesture is refused outright and costs the VIDEO > a live operator
toggle, who is looking at the screen > the item's stored flag.

shouldOfferUnmute() exists so the prompt only appears when a gesture is the ONLY
thing in the way. Prompting on a muted item trains viewers to click a button
that undoes an operator's decision.

Tizen gains enablejsapi + a postMessage bridge so a live toggle flips the embed
without reloading it — reloading would restart the video from zero every time
someone touched the control.

11 new tests pinning each precedence step separately; 1055 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:50:59 -05:00
ScreenTinker 4ed7954f84 Drop the user-agent fallback — it could never fire
isBrightSignDevice() fell back to device.user_agent to catch panels paired
before this port existed, which registered as "Chrome 120" with a BrightSign
user agent. `devices` has no user_agent column, so the field is always undefined
on a row read from the database. The branch was unreachable in production and
passed only in a test that fabricated the field — which is precisely how dead
code survives review.

Two agents flagged it independently while working on unrelated areas, and the
schema confirms it: zero matches for user_agent in the devices table.

Those pre-port panels are recognised the moment they re-register on a build
carrying the host, which every one of them gets on its next update. Identifying
them sooner would mean persisting the user agent, and a column added solely to
track a population that disappears on its own is not worth carrying.

The test now asserts the honest behaviour: a fabricated user_agent does NOT
create a match, and a group containing such a panel reads as mixed until it
re-registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:30:10 -05:00
ScreenTinker 039511b988 Merge: prove screenshot pixels arrived instead of assuming the draw worked 2026-08-05 10:23:42 -05:00
ScreenTinker 9c04e2c113 Merge: BrightSign offline content caching and package self-update 2026-08-05 10:20:50 -05:00
ScreenTinker 16b3dd949c Merge: BrightSign real telemetry and hardware identity 2026-08-05 10:18:06 -05:00
ScreenTinker 90553852bf Merge: BrightSign native sync, wired end to end and chosen per group 2026-08-05 10:15:22 -05:00
ScreenTinker 8fd6eb75d5 BrightSign: cache content for offline, and let the package update itself
Two gaps that both end the same way — a panel nobody can fix without a van.

OFFLINE. Content bytes were never persistently cached. The service worker
skipped /uploads/content/ and leaned on the browser's HTTP cache, which is
reasonable on a desktop and is not a documented-persistent store here:
BrightSign guarantees survival across reboots for IndexedDB, localStorage and
SQLite, and their own answer for offline video is to cache the bytes explicitly.
A panel could come back from a power cut with its playlist intact — that lives in
localStorage — and no media to play it with.

The reason content was skipped is real, and player-cache-policy.js is what makes
intercepting it safe. Seeking video issues range requests, and naive caching is
worse than none: storing a 206 as the whole file means every later full request
gets a fragment, and answering a range request with a 200 makes some media stacks
fail outright. So only complete 200s are stored, and ranges are served by slicing
the stored body into a correct 206. The content cache survives shell
re-versioning, or every deploy would re-download the playlist over a link that may
be exactly what is broken.

SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark
panel with no app underneath. The safety is the ordering: download to .part,
verify sha256 AND size, then delete the .done marker, rename, reboot. Marker
first is not stylistic — leaving it makes the next boot skip the archive and the
update silently never happens. A failed extract parks the zip as .bad instead of
retrying every boot, which would be a loop indistinguishable from a hardware
fault. sha256 because that is what roMessageDigest can compute; a checksum the
player cannot verify is an unverifiable package.

The decision lives on the server and is unit-tested, and the host only executes
it — re-implementing the version comparison in BrightScript would put the
prerelease trap somewhere untestable. That trap is honoured directly: a player on
1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player
HOLDS a prerelease of its own core rather than being pulled off the build it was
given to test. Narrowly — a newer core still lands, so opting in never means
never updating again.

Both loop conditions are closed by construction. The manifest and the download
come from one buffer hashed once, so a checksum cannot describe bytes we are not
serving. And the version is stamped into autorun.brs at build time by both
builders, so the script reports the version it actually is — otherwise the player
applies the update, still reports the old version, and is offered the same
package forever.

Failure always degrades to "keep running the old version": an unreachable
manifest, a missing checksum, a failed verification, a full attempt counter and
an unbuildable package all resolve to skip.

998 tests pass (was 954).
2026-08-05 10:09:00 -05:00
ScreenTinker 5a7277523a Wire BrightSign native sync end to end, chosen per group
st-sync.js wrapped SyncManager but nothing drove it. The player now does: the
leader opens a new sync session on each advance, and every member — the leader
included — binds the video with attachVideo() on a NEW id only.

The leader binds from its own broadcast rather than at announce() time on
purpose. Starting when it announces would put it ahead of its followers by the
width of the network, which is the one desync nobody would think to look for
because the leader always looks correct.

Item selection stays clock-derived under both backends. Native sync replaces
only the seek/nudge drift correction, because setSyncParams has the element hold
its own alignment and correcting it ourselves would fight the platform — every
frame we moved is one it then has to undo. Keeping selection on the shared clock
is also what keeps images and widgets, which have no setSyncParams, advancing
with the videos instead of drifting off alone.

LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding
a column. It already resolves pinned-if-online, else first online member on the
shared playlist, else first by id — deterministic, stable, and already what the
group-sync payload reports. A second mechanism could only disagree with it.

Added on top: a group whose elected leader is OFFLINE falls back to our
protocol. Ours is leaderless and carries on; native sync has exactly one
broadcaster, so those members would sit waiting for an announcement that never
comes, with the dashboard showing a healthy group throughout.

device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's
REQUEST; the answer comes from the existing pure resolveSyncBackend() so the
players, the dashboard and the stored setting cannot disagree. The resolved
backend, reason and downgraded flag ride in the group_sync payload and in the
group API, and the dashboard shows the refusal reason instead of a setting that
quietly isn't in force. An unrecognised value is rejected rather than stored,
because the resolver reads anything unknown as 'auto' — a typo would otherwise
return 200 and run a different protocol than the UI displayed.

FIXED WHILE HERE: the player re-entered group sync only when the group ID
changed, with a comment noting the clock protocol has no leader role. Native
sync has one, and neither a protocol switch nor leadership moving alters the
group id — so a player promoted to leader kept behaving as a follower, nobody
announced, and the group sat unsynchronised. The re-enter key now includes the
backend and the leader flag.

971 pass (+17).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:06:18 -05:00
ScreenTinker 46b2227dfd BrightSign: real telemetry and hardware identity, not a block of nulls
The web player sent battery/storage/RAM/CPU as nulls and 'Web Player' in the
wifi_ssid column, and the bs_model / bs_os_version / bs_serial / bs_screen fields
the player already reported at registration were consumed by nothing. A browser
tab genuinely has none of that. A BrightSign has some of it, and was reporting
none.

Telemetry comes from a CACHE the heartbeat reads synchronously. The beat builds
its payload every 15s without awaiting, but the one real sensor here —
deviceInfo.getTemperature() — returns a promise; awaiting inside the beat would
either block it or serialise a pending Promise into the payload, which is exactly
how device_id once became "[object Promise]". The cache starts EMPTY rather than
null-filled and is spread last, so off-platform nothing changes and a null here
can never clobber a value another player family legitimately supplied.

wifi_ssid was actively false on a PoE Ethernet appliance — an operator reading
that column was told an SSID that does not exist. It is null there now, and the
device view shows a real hardware block instead. Android's WiFi display is
untouched.

Hardware identity is a SEPARATE writer from applyDeviceInfo, deliberately. That
function is a blind full-row overwrite, and an empty device_info once nulled
seventeen columns every five minutes because {} is truthy. These fields arrive
only on a full register, so the same shape would wipe them on every lightweight
refresh in between; COALESCE makes "no news" mean "unchanged".

The OS build gets its own column rather than reusing android_version, which is
load-bearing as a TYPE discriminator: device-detail chooses between the Android
and browser layouts with android_version.startsWith('Web/'), so writing
"BrightSign OS 9.0.189" there would have rendered a BrightSign with battery and
WiFi cards — and applyDeviceInfo would have clobbered it on the next refresh.

Storage is labelled "Player Storage", not "Storage": on this family the number is
the widget's cache quota, not the device filesystem, and it lands in the same
column as Android's real disk figures.

Schema: device_telemetry.temperature_c REAL; devices.hardware_model,
hardware_serial, hardware_os_version, output_index. All nullable, all idempotent
in the existing migration array.

973 pass (+19). The temperature tests drive a real socket into a real server,
because changing the arity of the telemetry INSERT would break every player's
heartbeat, not just BrightSign's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:03:33 -05:00
ScreenTinker e606cc83d1 Screenshots: prove pixels arrived instead of assuming the draw worked
A BrightSign emitted BLANK screenshots and logged "Screenshot sent". With hwz
enabled the video decodes onto a hardware plane outside the browser compositor
— BrightSign's docs say the HTML/JS layer "doesn't see the pixels" — so
drawImage(video) produces a fully TRANSPARENT image and throws nothing.
Chromium 87, which this XT245 reports, fails the same way.

Both capture paths set captured/drawn = true purely because drawMediaFit() had
not thrown. So the dashboard showed a dead screen while the panel played
perfectly, and the zone path painted a black rectangle in place of the labelled
placeholder drawZonePlaceholder() exists to guarantee ("never a transparent
hole"). Success reported, nothing done.

isMediaReadable() does not catch this. It answers "am I ALLOWED to read this"
(same-origin / CORS), which is a different question from "did any pixels
arrive".

videoFrameIsCapturable() probes a 16x16 scratch canvas before committing to a
full-size draw. ALPHA is the discriminator, not colour: a scratch canvas starts
transparent and a real decoded frame writes alpha=255 even when the frame is
pure black, so a legitimate fade-to-black still reads as captured while
"nothing arrived" does not. A tainted canvas counts as captured, because
tainting only happens once cross-origin pixels have actually been drawn.

Probing BEFORE the draw matters twice: it avoids a wasted full-size drawImage on
every frame of a 1fps stream, and in the zone path it stops a black rectangle
being painted underneath the placeholder.

When a video is on screen but unreadable the status card now says so, because
that card is also what shows for "no content" — without the line an operator
would reasonably conclude the screen was blank.

Not gated on BrightSign: the same silent failure exists for any stalled decoder
or engine that declines to hand back frames.

10 tests, 964 pass.
2026-08-05 10:01:03 -05:00
ScreenTinker 141deb97a5 screen_off must tear the video down — a DOM overlay cannot cover a hardware plane
Blanking the screen took three attempts on real hardware, and each failure was
the same lesson from a different angle:

  1. black overlay        -> the video played straight THROUGH it. With hwz
                             enabled the widget decodes onto a hardware plane and
                             the graphics plane sits behind it; z-index is
                             irrelevant across planes.
  2. pause + hide element -> playback stopped and the LAST DECODED FRAME stayed
                             on screen. Hiding a DOM element does nothing to the
                             plane, which is not part of the DOM.
  3. pause + remove src   -> releases the plane. Black.
     + load()

Coming back re-mounts through nextItem(), because a torn-down element cannot be
resurrected. The playlist keeps advancing while the screen is off, so each newly
started item is torn down as well — caught on 'play' in the capture phase, or the
next video lights the panel back up a few seconds later.

CEC is now explicitly not load-bearing. Our XT245 logs "failed to get cec clock"
and does not respond to it at all, which is precisely why blanking cannot depend
on a cooperative display: plenty ignore broadcast CEC or need direct addressing.
displayPower() stays as opportunistic best-effort alongside the teardown.

Verified on hardware: not black, then frozen frame, then black.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 09:40:17 -05:00
ScreenTinker c743aa4b81 BrightSign: real display power, reboot and volume — command parity
The web player handles four of the ~20 fleet commands, because a browser tab
genuinely cannot do more. A BrightSign can, and was inheriting the browser's
limits for no reason.

screen_on/screen_off now send CEC Image View On (0x0D) / Standby (0x36) so the
display actually sleeps. The overlay only painted the screen black: the panel
stayed lit, drawing power and at risk of burn-in. Best effort by design — some
displays ignore broadcast CEC and need direct addressing — so displayPower()
returns false when unavailable and the overlay is applied either way, meaning
something visible always happens.

reboot was silently ignored: the dashboard button did nothing on a web player.
It now goes through the host to RebootSystem, and still logs a clear "not
supported" off-platform rather than failing quietly.

set_volume applies to whatever is playing AND is re-applied on every subsequent
'play' event, caught in the capture phase because media events do not bubble.
Media elements are created per item across fullscreen, zone and preload paths,
so setting volume once would otherwise last only until the playlist advanced.
Wall followers stay silent throughout — that is deliberate, not an oversight.

A dual-output player addresses HDMI-N for the screen it actually paints, so
output 2 sleeps its own display rather than output 1's.

954 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 09:26:23 -05:00
ScreenTinker cb4376d9cd Actually attach autorun.zip to the release
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 rc2 workflow built autorun.zip and then published a release without it: the
edit that was supposed to add it to the gh release create asset list never
applied, and nothing asserted that it had. Built artifacts that quietly fail to
ship are worse than ones that fail loudly — the release looked green.

Attached to rc2 by hand; from rc3 the workflow does it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 00:14:29 -05:00
ScreenTinker 5ce094b1f8 chore(release): v1.9.29-rc2 2026-08-05 00:06:20 -05:00
ScreenTinker fac6071813 Changelog for 1.9.29-rc2 2026-08-05 00:06:18 -05:00
ScreenTinker f86195df53 BrightSign: autorun.zip installer, built and shipped with every release
Four loose files that must all land intact, in the right place, is a poor way to
hand someone a player. autorun.zip is one file: drop it on the root of a
player's storage, power-cycle, and autozip.brs unpacks it in place and reboots
into the player. A half-copied set of loose files boots into something broken; a
half-copied zip simply fails to extract and leaves the player as it was.

Two rules the format imposes, both of which fail SILENTLY when broken, so the
build script asserts them instead of trusting them:

  - the archive must expand to files at its root, with no wrapper directory. A
    player extracts to the storage root, so a nested folder puts autorun.brs
    somewhere the player never looks and the card appears to do nothing.
  - autorun.brs must not sit next to autorun.zip on the storage root; its
    presence stops the zip being processed at all.

autozip.brs renames the archive to autorun.zip.done after a successful extract,
which is what makes it idempotent — without that the player extracts, reboots,
extracts, reboots, a loop indistinguishable from a hardware fault. A FAILED
extract deliberately does not rename, so a truncated copy gets retried once
someone replaces it rather than being skipped forever.

It is volume-aware for the same reason autorun.brs is: a player may be booting
from internal flash because its card interface is dead, and extracting to "SD:/"
on such a unit writes to a volume that does not exist.

--server rewrites screentinker.json in the staging copy so a batch can be imaged
for a specific instance without hand-editing anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 00:06:00 -05:00
ScreenTinker 58641e7bbe Persist the device token, not just the device id
The bridge stored device_id in the registry and the display still came back as a
NEW device on the next boot. The id is not an identity on its own: the server
authenticates a claim to an existing display with the token, so an id presented
without one reads as a brand-new player and gets a fresh row.

device_token now sits alongside device_id in the registry, getConfig adopts both,
and clearIdentity forgets both — a stale token must not outlive the identity it
belongs to.

Found on an XT245, not in a test, which is why the three new cases name the
symptom rather than the mechanism. 951 pass.

Also worth recording from the same session: the duplicate rows had a second
cause. The widget's storage_path was pointing nowhere useful, so localStorage
had no persistent home and the per-install fingerprint salt was regenerated on
every boot. With storage_path set correctly the cache directory now exists on
the player and the fingerprint is stable, which is what stopped the churn; the
registry identity is the belt to that pair of braces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 23:57:04 -05:00
ScreenTinker 5cd56344d2 Ship brightsign/ in the image — the player assets 404 in a container
The player loads /player/st-bridge.js and /player/st-sync.js, both served from
../brightsign so the copy the player runs can never drift from the copy sitting
on the player's own storage. That runtime path only exists if the directory is
in the image, and the Dockerfile never copied it — so both routes 404 on alpha
while working perfectly from a dev checkout.

Caught by deploying 1.9.29-rc1 to alpha, which is the whole point of alpha.

Worth noting how this fails when the route is absent entirely, as on prod today:
the SPA fallback answers 200 with text/html, so the browser gets a page where it
expected JavaScript, window.ScreenTinkerBS is never defined, and the player
silently falls back to browser behaviour. A missing asset that returns 200 is
considerably harder to notice than one that 404s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 23:40:19 -05:00
ScreenTinker ad18914736 Label BrightSign players as BrightSign, not "Web Player"
A BrightSign runs the same web player, so client_type is 'player' and the device
detail view fell through to a hardcoded "Web Player" — indistinguishable from a
browser tab on someone's desk, for a dedicated signage appliance.

Keyed on the platform the player now reports ('brightsign', from the
?platform=brightsign the host puts on the URL), with a user-agent fallback for
panels paired before that existed — those registered as "Chrome 120" with a
BrightSign user agent.

Only en carries the new string; other locales fall back to en, which reads
correctly since the label is a brand name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 23:31:56 -05:00
ScreenTinker b15b17f5dd chore(release): v1.9.29-rc1 2026-08-04 23:29:37 -05:00
ScreenTinker 4e625abf50 BrightSign: boot from internal flash, proven on hardware
An XT245 with liquid-corroded microSD lines could not read any card, in any
format, with known-good code — the kernel log shows the mmc1 host probing at
400kHz and no card ever answering, while mmc0 (eMMC) is healthy. That unit
turned out to be fully deployable anyway: the player boots FLASH:/autorun.brs
straight from internal storage.

    Loading 'FLASH:/autorun.brs'
    BSPLAY: https://screentinker.com/player?platform=brightsign&model=XT245

So the card is not the only path, and a dead slot is not the end of a player.
Files go to /storage/flash over SFTP and the player runs them on the next boot.

The first attempt failed because the script hard-coded SD: for its own assets:
it loaded from flash and then could not find index.html. StorageRoot() now
probes for FLASH:/autorun.brs and falls back to SD:, and every path that reads a
sibling file — screentinker.json, offline.html, the crash-dump directory — goes
through it.

selftest/ is the bisect that settled the hardware fault: the dev-cookbook's own
html-starter pattern, so the script is not a variable. When known-good code
failed identically, the medium was proven at fault rather than our port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 23:26:43 -05:00
ScreenTinker 5901067d8a Finish the BrightSign port: native sync, offline fallback, multicast guard
st-sync.js wraps SyncManager, the native protocol. Three properties drove the
shape of it. It repeats the sync broadcast at 1Hz so a player powered on late
still joins, which means acting on every repeat would reload the video once a
second forever — on screen that reads as a stutter, not as a sync fault, so the
id dedupe is mandatory rather than an optimisation. The leader starts from its
OWN broadcast rather than at announce() time, or it runs ahead of the group by
the width of the network. And attachVideo refuses an element with no
setSyncParams instead of half-syncing it.

offline.html is the local fallback the host falls back to after three failed
loads. It names the server, keeps probing with capped backoff so a site full of
panels cannot storm a server that is coming back, and asks the HOST to restart
the player when it answers — never navigating itself, for the same reason the
player never reloads itself here.

The resolver now models multicast reach. All-BrightSign groups spread across
subnets no longer get native sync: each subnet would sync neatly within itself
while drifting from the others, and the dashboard would show a healthy group
throughout. The IP comparison is a heuristic so it is used in one direction
only — differing networks are evidence against, matching ones are never proof
for, and unknown addresses block nothing.

st-sync.js is served from its single source like the bridge, and the SD card
deliberately carries neither: the player pulls both from the server so a stale
copy on a card can never skew from the player using it.

948 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:43:42 -05:00
ScreenTinker bc68cd2752 Drop a stale README claim — the bridge is wired into the player
The "not done yet" list still said the player does not load st-bridge.js or
honour ?platform=brightsign. Both landed in ce854ff. Replaced with what is
actually outstanding: nothing server-side consumes the bs_* fields the player
now reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:28:07 -05:00
ScreenTinker 7fb94fbf70 Correct the BrightSign port against the dev-cookbook examples
Reviewed autorun.brs and st-bridge.js line-by-line against the real examples
instead of the prose docs. Five defects, three of which would have been silent.

The registry API is asynchronous and section-oriented: read(section, key)
returns a Promise and writes take an object, write(section, {k: v}). The bridge
treated both as synchronous, so deviceId() returned a Promise object — truthy
and non-empty — and a panel would have registered as "[object Promise]" while
its real row sat unclaimed. It now prefetches into a cache behind onReady(), and
connect() waits for that before registering.

brightsign_js_objects_enabled: true is required alongside nodejs_enabled for
require("@brightsign/*"). Without it the bridge degrades to no-ops and the
player loses identity and restart delegation — which would have read as
"BrightSign doesn't work" rather than as one missing flag.

storage_path is a directory name, not a volume, and storage_quota is a string;
the local fallback URL needs its volume (file:/SD:/offline.html). Added
security_params and hwz_default to match the examples.

SyncManager does not work unless networking/ptp_domain is "0", which needs a
reboot to apply. Done only when this player is configured for native sync, and
read-before-write so it reboots once rather than on every boot.

Confirmed correct as written: messageport, the roHtmlWidgetEvent loop, and
RebootSystem(). The notes also state a widget URL may be an externally hosted
page with the same JS API access — the favourable answer to the question the
original probe was built to ask.

Bridge tests now model the async section-oriented registry, so a synchronous
stand-in can never hide this class of bug again. 931 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:27:53 -05:00
ScreenTinker fa68c8b7e3 Record the SyncManager API — the brightsign backend is no longer a blank
The README claimed the runtime sync API was undocumented. It is not in the MCP
doc set, but docs.brightsign.biz/developers/syncmanager and the dev-cookbook
syncmanager-js example document it fully, so that claim was wrong and is now
replaced with the actual contract.

The useful discovery is that it is pure JavaScript on the standard <video>
element: setSyncParams(domain, id, iso_timestamp) followed by load()/play(),
after which the element handles ongoing synchronisation itself. No BrightScript
round-trip, so it drops into the existing player.

Three constraints worth having written down before anyone implements it: it is
leader/follower where ours is leaderless, it synchronises video only so images
and widgets get item-boundary alignment at best, and it is multicast so a group
spanning sites or VLANs cannot use it — a criterion the resolver does not model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:18:40 -05:00
ScreenTinker ce854ff2d8 Wire the BrightSign bridge into the web player
The bridge and the host existed but nothing loaded them. Now the player does.

restartPlayer() replaces every location.reload() call site. On BrightSign a
page-initiated reload does not reliably bring the roHtmlWidget back, so the page
asks the host to rebuild it and only falls back to reload() when no host is
there to take the request. That covers the deploy path, the operator refresh,
the service-worker activation and the manual reset.

Identity now round-trips through the registry, which outlives localStorage on
this platform: getConfig() adopts a registry identity when local storage comes
back empty, instead of re-pairing and spawning a second row for a panel that is
already provisioned. The operator reset clears the registry too — otherwise it
would clear localStorage, get the same identity straight back on the next boot,
and reset nothing.

Registration reports platform 'brightsign' rather than "Chrome 120", which is
what sync-backend.js resolves native-vs-ours from, plus model, OS, serial and
which output this widget paints.

Dual output needed a collision fix: autorun.brs gives the second HDMI output its
own widget, and both widgets share an origin, a registry and one SD
storage_path. Un-namespaced, output 2 would read output 1's config, install salt
and device id and the two would collapse into a single device row. Storage keys
and registry keys are now suffixed per output; screen 1 keeps the bare names so
nothing existing moves.

The bridge is served from its single source so the copy the player loads can
never skew from the one on the SD card next to autorun.brs, and it is served to
every player rather than gated on a user agent — a panel reporting an unexpected
UA would otherwise silently lose restart-instead-of-reload.

Two test harnesses extract player functions and run them in an isolated scope,
so they now supply SCREEN_SUFFIX; one gained a case proving two outputs of one
player get distinct identities. 927 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:07:47 -05:00
ScreenTinker 6f5907a1d4 BrightSign: supervised player host, JS bridge, and per-group sync backend
The player is the unmodified web player in an roHtmlWidget — that already runs
on real hardware. What was missing is everything a page cannot do for itself.

autorun.brs becomes a host rather than a URL wrapper. It owns the widget
lifecycle, because a page-initiated location.reload() does not reliably bring an
roHtmlWidget back: a deploy on 2026-07-28 reloaded every connected player and
the BrightSign was the only one that never returned. The page now posts
{type:"restart"} and the host rebuilds the widget. It also retries load-error
with backoff, falls back to a local page, and runs a heartbeat watchdog that
catches the case load-error never reports — a page that loaded fine and then
wedged on a dead socket or a stalled decoder.

st-bridge.js is the page's half over @brightsign/messageport: registry-backed
identity (localStorage is origin- and quota-bound, the registry is not),
restart-instead-of-reload, heartbeat, and sync-backend reporting. Every method
degrades to a no-op off-platform, so it is safe to load unconditionally.

sync-backend.js decides whose synchronisation a group runs. Ours is
clock-derived and spans any mix of Android, web, Tizen and BrightSign; BrightWall
is frame-accurate and BrightSign-only. auto picks native when every member is a
BrightSign. The refusal that matters: native sync selected for a mixed group
downgrades and says why, because a half-synced group would look perfectly
synchronised on the dashboard while one panel drifted alone.

Dual output via output_mode single|dual|clone — a second widget loads the same
player with &screen=2 so the server can give it its own playlist.

Written against the BrightDeveloper docs; not yet run on hardware. The README
lists what is unimplemented, including the BrightWall runtime API, which that
doc set does not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 20:26:12 -05:00
ScreenTinker 88f2c63229 Add scripts/force-update.js — operator CLI to force a check on one display
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
A display whose periodic update checker stops firing never pulls an APK on its
own, and a beta-channel opt-in alone doesn't reach it. The dashboard's force
button is the only lever that does, because the client's "update" handler calls
checkForUpdate(forced = true), which ignores both the backoff cap and the MDM
stand-down and hands the attempt budget back.

That command only exists over the /dashboard socket.io namespace, so there was
no way to send it from the server. socket.io-client isn't a dependency here, so
this speaks engine.io v4 directly over ws (reached out of server/node_modules,
same convention as reset-admin.js).

Owner-only by construction like mint-billing-token.js: no network endpoint, the
access control is shell access to the host. Resolves a display by id or unique
prefix, mints a short-lived platform_admin token, and reports whether the
command was delivered or queued for an offline display. --dry-run stops after
the namespace handshake so a rehearsal never puts an install dialog on a live
screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-01 16:19:33 -05:00
Claude fca36c242a Open our own permissions screen from the in-service Settings menu
The Permissions entry showed a ✓/✗ read-out and then handed off to Android's App Info page. The
screen we actually built for this — a row per permission with its live state and a Manage button
that stays visible once granted — was only reachable during first-run setup, so an installer who
wanted to review or revoke something on a running panel had to re-pair to see it.

Manage Permissions is now the primary action and opens SetupActivity in review mode. Android's App
Info page stays as the secondary, because notification access and some OEM toggles are only
reachable there.

Review mode exists because three things in SetupActivity assume first-run, and every one of them
had to be exempted or this silently did nothing:

  - proceedToNext() goes unconditionally to ProvisioningActivity. Without the exemption the button
    an installer was told to press would send a paired, playing screen to the pairing page.
  - onCreate returns early when setup_complete is set — and every device that can reach this menu
    has it set, so the screen closed before it drew and the menu entry looked broken.
  - updateStatuses() re-labels the continue button on every refresh, silently overwriting the label
    set in onCreate. The label had to move to where it actually sticks.

Review mode also hides the first-run skip hint, does not re-stamp setup_complete, and returns to
playback rather than continuing anywhere.

Verified on an Android 12 emulator, both directions:
  in service  BACK x2 -> PIN -> Settings -> Permissions -> MANAGE PERMISSIONS -> our screen with
              every row and its state -> DONE -> back to playback, no ProvisioningActivity launch,
              widget rendering resumed
  first run   full uninstall + fresh install -> SetupActivity, button reads CONTINUE ANYWAY, skip
              hint present, no DONE label, continue lands on ProvisioningActivity, pairing completes
              and playback starts

That second run is the one that mattered: both early-exit guards are inverted conditions, and a
mistake in either would have broken onboarding for every new install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-01 14:31:15 -05:00
ScreenTinker ff7bfb2ded chore(release): v1.9.28
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-07-30 23:02:18 -05:00
Claude 921c7ce3bb docs(changelog): 1.9.28 — platform QA sweep, 25 fixes 2026-07-30 23:01:41 -05:00
ScreenTinker d51138624e Merge fix/qa-sweep: 25 fixes from the platform QA audit 2026-07-30 22:57:39 -05:00
Claude ccbd63ba79 Stamp the authenticated device on relayed playback progress
device:playback-state was the only relay that forwarded the client's payload verbatim. The workspace
lookup correctly used currentDeviceId — the socket's authenticated device — but the object passed on
to the dashboard was whatever the player sent, including any device_id it chose to put there. So one
device could report playback progress attributed to a different screen in the same workspace, and
the dashboard had no reason to doubt it.

Every other relay in this file stamps the authenticated id. This one now matches.

882 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:41:06 -05:00
Claude a310d7d5b6 Stop the onboarding checklist counting a field no player reads
"Default Content" is persisted by the device route, snapshotted and restored by the settings layer,
offered in the device form in five languages — and read by nothing. Grep the whole tree and it
appears only in those places, the schema, and this checklist. It is absent from assemblePayload,
from every socket payload, and from all four players.

Counting it as "content assigned" therefore told the operator their screen was set up while the
screen itself went on showing "waiting for content" — the checklist confirming the one thing it
exists to confirm, incorrectly. It now counts only a playlist or a layout, both of which really do
put something on a display.

An existing test asserted the opposite ("any of the three ways of assigning counts"). It encoded the
same false premise, so it is replaced by one that pins the corrected behaviour along with the
evidence for it. The column and the form field are left alone — whether to implement or remove the
feature is a product decision, and this change only stops the checklist making a claim on its
behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:41:06 -05:00
Claude 9458af95fd Show the idle card on a group-synced screen when nothing is scheduled
Give every item on a sync-group playlist a daypart — "menu boards 06:00-22:00" — and at 22:00 the
whole group kept displaying, or looping, whatever had been in-window last. An identical ungrouped
screen showed "Nothing scheduled right now" correctly.

The group schedule tick filters items by the same scheduleAllows check as solo playback. With
everything filtered out the period is zero, so the target is null and the tick simply returned.
Nothing else was watching: group members are schedule-driven, so renderContent arms no advanceTimer,
and a group-rendered video is created with loop = !!groupSync. Solo playback routes this exact
condition into the idle card; group playback had no equivalent, on either player.

Both ticks now tear down and show the idle card when the schedule has nothing live, and pick up
again when the daypart re-opens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:30:16 -05:00
Claude 47bda040a2 Re-render when a screen leaves a sync group or a video wall
Taking a display out of a sync group, or deleting the wall it belonged to, froze it on whatever was
playing. The clip looped forever and every later refresh took the "unchanged" branch, because the
element was attached, playing and un-errored — healthy by every check the player makes. Only a
reboot cleared it.

On the web player, reconcileAdvanceTimerForMode re-arms a solo timer for widgets and images but
skips video and YouTube, on the grounds that they "self-advance via their own end handlers". The
handler that is live at that moment, though, was built for the mode being left: a group-rendered
video was created with `loop = !!groupSync`, a wall-follower video with `isFollower` true, and both
are captured in the closure at render time. A looping element never fires `ended`, and a follower's
handler declines to advance — so nothing self-advances and nothing re-renders. It now re-renders
whenever the element on screen is still looping, rather than guessing which media types can look
after themselves.

Tizen had the same freeze by a different route. GroupSyncController.exit and WallController.exit
both call player.invalidate() for exactly this purpose, but invalidate only cleared the change
signature — and load() returns at the continuity check ("current item survives, just retarget the
index") before reaching any render, so the invalidate was a no-op. It now forces the next load to
re-render, which is what those call sites always intended. On Tizen this froze every item type, not
just video, because `single` skips the timer in all of the renderers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:29:06 -05:00
Claude e3b01a5c7f Make a content-only schedule actually put that content on the screen
The schedule dialog offers "Content (single item, optional)". The value was cross-tenancy validated
and stored faithfully, and then read by nothing. services/scheduler.js acts on exactly two columns,
layout_id and playlist_id; content_id is consulted nowhere in the codebase. So picking a file and
saving produced a schedule that fired and changed nothing — while the calendar drew a block labelled
with that filename, as confirmation that it would.

Rather than thread a third override type through the engine and every player, the schedule now gets
a playlist containing that one item. That is the shape the entire pipeline already understands:
publish, assign, push, snapshot, offline cache and all four players work on it unchanged.

It is published through the shared publishPlaylist path rather than by hand-rolling the snapshot,
because players read denormalized fields out of published_snapshot (filename, mime_type, filepath,
remote_url, per-item schedules) and a second copy of that shape here would rot the first time it
changed.

An explicit playlist override still wins and no throwaway playlist is created; a schedule with
neither content nor playlist is untouched.

5 tests covering all of those, including that the generated playlist lands in the right workspace and
that its snapshot carries the fields the players need rather than just the id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:26:58 -05:00
Claude 3c1b2f62ee Make a recurring schedule respect its start and end dates
A recurring schedule ran forever. The engine compared weekday and HH:MM and dropped the date
component entirely, so recurrence_end was never read: a campaign set to finish on the 1st was still
switching screens weeks later. The same omission made a recurring schedule live before its start
date.

The calendar does read recurrence_end, so it drew the campaign as finished while the screens kept
obeying it — the two views disagreeing is what made this hard to see from the dashboard. The end
date is offered on the form, so it has to mean something.

The date window is inclusive at both ends: an end date of the 5th means the 5th runs to its normal
end time, which is what someone choosing that date means. An open-ended recurring schedule is
untouched and still runs indefinitely.

NOTE, because this one really does change live screens: any recurring schedule that has been running
past its end date will now stop. That is the intended behaviour and was confirmed before making the
change, but it is the difference between this commit and the calendar fix alongside it, which
changes only what is drawn.

6 tests: stops after the end date, the final day still runs in full, does not run before the start
date, unchanged inside the window, open-ended schedules unaffected, one-offs unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:22:59 -05:00
Claude 416984d56b Draw a recurring schedule on every day it actually fires
The calendar is the operator's only view of what is scheduled, and it disagreed with the engine in
both directions for the two most-used repeat presets.

The expansion stepped by the recurrence unit from the schedule's original start:

  - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
    FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule could only ever match its start day. Created on a Monday
    it drew one event a week; created on a Saturday it drew nothing at all.
  - The walk began at the original start under a 366-iteration cap, so a schedule begun more than a
    year ago never reached the current week and drew nothing.

The engine evaluates day-of-week directly, so those schedules were running Mon-Fri the whole time.
Screens switched content the calendar said was not scheduled.

The expansion now walks the visible range day by day and applies the same rule the engine does, so
the drawing follows what actually happens. Cost is bounded by the window being displayed rather than
by how long ago the schedule was created, and the loop re-anchors the time of day on each step so a
DST boundary does not drift the instances.

Overlap is left to resolve as it already does: a shorter, higher-priority schedule takes over while
it is active and the recurring one resumes underneath when it ends. Nothing here changes what fires
— only what is shown — so this cannot alter live screens.

8 tests: five events for a Mon-Fri rule whichever day it was created on, a two-year-old daily
schedule drawing again, WEEKLY-without-byDay still meaning the start's weekday, INTERVAL honoured,
recurrence_end stopping the drawing, one-offs unaffected, and durations preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:18:29 -05:00
Claude 80c8c81fb8 Check that a schedule's zone belongs to the caller's workspace
Creating a schedule validates every reference it carries against the caller's workspace — content,
widget, layout, playlist all go through checkRefInWorkspace. zone_id was the one polymorphic
reference left out of that list, so a schedule could be pointed at a zone belonging to another
workspace's layout.

It needed its own check rather than a sixth entry in the table: layout_zones has no workspace_id
column of its own. A zone belongs to a layout, and the layout carries the workspace, so the
ownership question has to be answered through that join. A zone on a platform-template layout
(workspace_id IS NULL) is allowed, matching how the other references treat templates.

882 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:54:24 -05:00
Claude 4a65c4cec7 Tell the screens when the playlist they are showing is deleted
devices.playlist_id is ON DELETE SET NULL, so the database detached correctly — but the handler
emitted nothing, so a screen kept displaying the deleted playlist until it happened to reconnect or
was restarted. You delete a playlist to take content off the wall; the wall carried on showing it.

Every sibling mutation in this file already pushes (publish, assign), and DELETE
/devices/:id/playlist was given a push for precisely this reason: "so the screen stops, rather than
leaving the old content up until something else happens to update it".

The affected devices are read before the delete, since the association is gone the moment it runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:50:50 -05:00
Claude 9ea1b5e07b Stop eight dashboard views reporting success for requests the server refused
Each of these views carries its own copy of a fetch helper ending in `.then(r => r.json())`. A 403,
404 or 500 body resolves as an ordinary value, so the surrounding try/catch is unreachable and every
handler treats the failure as success. The shared client in api.js has always thrown on !res.ok;
these local copies never did.

Two concrete consequences, both of which tell the operator something untrue:

- The layout editor renders a Delete button on built-in templates for everyone. The server returns
  403. The handler shows "Layout deleted" and re-renders the list with the template still sitting
  there.
- A rejected platform-role change in Admin shows "Role updated", and the revert that would put the
  dropdown back lives only in the dead catch — so the UI keeps displaying a value the server
  refused. The same control in Settings uses the throwing client, so the two pages disagree about
  whether the change happened.

All eight now match the shared contract: reject on !ok with the server's own message, and treat 401
as session expiry the way api.js does.

This makes previously-silent failures visible, which is the point — some of them will surface
refusals that were always happening. The layout template Delete button, for instance, is now
honestly reported as refused rather than falsely confirmed; whether that button should be shown at
all is a separate question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:21:56 -05:00
Claude d978a5d2a6 Make per-item scheduling work on Android 7 instead of blanking the screen
ScheduleEval uses java.time — Instant, LocalDate, ZoneId — which is API 26. minSdk is 24, and core
library desugaring was never enabled, so on Android 7.0/7.1 the first evaluation threw
NoClassDefFoundError. Those API levels are still common on cheap signage sticks and older TV boxes.

The damage was much worse than a failed check, because NoClassDefFoundError is an Error, not an
Exception. The evaluator's deliberate fail-open guard — written so that "a blank screen is worse
than an over-running promo" — did not catch it. The Error propagated out of scheduleAllows, through
firstActiveIndex and updatePlaylist, past another catch(Exception), and was only swallowed at the
service boundary. Because updatePlaylist aborted before the download block, no content was fetched
either; and on a cold start from cache the same Error reached a handler that clears the playlist
cache. So the moment anyone used dayparting or expiry, those panels sat on "waiting for content"
with nothing downloaded and nothing cached, and a reboot did not help. The stated contract was
inverted on exactly the hardware it was meant to protect.

Two changes. Desugaring is the real fix: java.time now exists on API 24/25, so the code runs as
written. The guard is widened to Throwable as well, so this class of failure can never again slip
past a catch that was written to be total — that is belt and braces, not the fix.

Release build assembles cleanly with desugaring on; 134 Android JVM tests green. Still to confirm on
a real API 24/25 image before release — the unit tests run on the JVM, where java.time always
exists, which is precisely why this was invisible to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:20:41 -05:00
Claude f4d309a0d4 Apply a playlist change even when the outgoing item never advances
Replacing the single item of a one-item playlist did nothing. The old promo, board or clip kept
playing while the dashboard showed the new playlist published and the device perfectly healthy —
only a reboot or a manual refresh cleared it.

#157 defers a rotation so a live item is not yanked mid-play, and applies it "on the next natural
advance". For a one-item playlist there is no such thing, by design: single-item rendering
deliberately never advances. A video gets `loop = (playlist.length === 1)` and so never fires
`ended`; a YouTube embed loops for the same reason and skips its safety net; a solo widget is "held"
on a self-re-arming refresh that never calls nextItem, because reloading it would reset a directory
board's scroll. Tizen is worse still — `single` makes every renderer skip its timer, so images
freeze too.

Two guards, the same pair already applied to the Android controller:

- A one-item playlist is never deferred. There is nothing to protect from being cut off, since
  nothing was going to advance anyway.
- Any deferral that does happen gets a 60-second deadline. The deferral is a bet that an advance is
  coming; if the bet loses, the change must still land rather than strand the screen on content the
  operator has already replaced.

Verified in headless Chrome: a one-item playlist holding a solo widget (the "held" case that never
advances), its only item replaced with a different widget — the screen followed, with no reload and
no restart. Before the change it stayed on the replaced item indefinitely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:17:44 -05:00
Claude d3f6af831b Recover a zone whose video fails, instead of leaving that region black
In a multi-zone layout a zone's video advanced only on `ended`. On the web there was no error
handler and — alone among the zone branches, which all arm a timer — no timer either. On Android the
zone player listened for STATE_ENDED with no error listener and no fallback.

A playback error lands in STATE_IDLE, never STATE_ENDED, so nothing advanced. A 404, an unreachable
remote_url, a clip the device cannot decode, or content not yet cached while the device is offline
(the zone then falls back to the server URL, which fails with no network) all had the same result:
that region of the screen went black and stayed black for days, while every other zone kept rotating
normally. It reads as a rendering bug rather than a bad file, and nothing self-heals — the layout has
to change or the app has to restart.

Both fixes already existed elsewhere and were simply not carried across. MediaPlayerManager treats a
playback error as a completion for exactly this reason ("Root-2: a corrupt/undecodable video used to
freeze the playlist forever"), the fullscreen web path has both an onerror and a timer, and Tizen's
ZoneRenderer has an onerror plus a duration+5s safety net. The multi-zone paths were the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:11:59 -05:00
Claude f66c941c1d Stop the content edit dialog rewriting types it cannot represent
Opening Edit on a YouTube item and pressing Save Changes — with nothing else touched — turned it
into an MP4.

The type dropdown offers six fixed options and is rendered unconditionally. For video/youtube no
option matched, so the browser selected the first one, video/mp4. The save handler then reads the
select's value and sends it because it differs from the stored type:

    const mimeType = overlay.querySelector('#editMimeType').value;   // 'video/mp4'
    if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;

and the server stores what it is sent. mime_type is the renderer selector in every player, so the
item became an "MP4" whose source is a YouTube embed page: a dead slide on every screen in the
playlist. It could not be undone from the dialog either, because there is no video/youtube option to
set it back, and the YouTube-specific controls disappear once the type has changed.

The same applies to uploads the sniffer accepts but the list omits — the sniffer allows fifteen
types, the dropdown covers six — so .mov, .svg, .heic, .avif and .bmp were all rewritten the same
way.

The dialog now includes the item's actual type as a selected option whenever the fixed six cannot
express it, so opening and saving is a no-op and the type is never silently changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:09:59 -05:00
Claude a3b668d32f Treat an empty device_info as "nothing new", not as "forget what you know"
Every web and BrightSign player nulled seventeen of its own device columns every five minutes.

The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has nothing
new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is a blind
full-row overwrite with no per-field presence check, so it bound undefined for every column.
better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and the row was
quietly emptied: android_version, app_version, screen_width/height, render_*, ota_status and
attempts, tier, the four capability flags and the four volume/brightness columns.

Android never hit it, because it always sends the full object. So this degraded exactly the client
family that cannot be inspected any other way — a browser player has no adb, and the dashboard row
is all there is. Fleet view, resolution diagnostics and any version-based logic read blank for them,
which also makes evaluating a browser-based platform look worse than it is.

The surrounding code already anticipates the refresh shape: recordReconnect and persistIdentity are
both gated behind `if (!isPlaylistRefresh)`. This call was the one that was not.

5 tests, including one pinning the driver behaviour the bug depended on — undefined binds as NULL
rather than throwing, which is why this was a silent five-minutely wipe instead of a loud error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:09:11 -05:00
Claude fb8cafc444 Web player: survive the suspended-account card destroying the status element
The suspended branch replaces the whole status overlay with its own markup, and that markup does not
contain #statusText. showStatus then did:

    document.getElementById('statusText').textContent = msg;

so every later call threw a TypeError for the life of the page. The consequences got worse the
further down they went:

- Each refresh beat re-emits device:paired, whose handler calls showStatus('Waiting for content...')
  — so the player raised an uncaught error and sent itself a "crashed" exit beacon every few minutes
  while suspended. This is very likely the "Cannot set properties of null (setting 'textContent')"
  the comment near the exit-signal contract says could never be traced.
- showNothingScheduled() calls showStatus BEFORE arming its 30-second re-check. So once the account
  was restored, a playlist whose dayparts had all closed left the screen on the stale orange
  "Account Suspended / Please upgrade your plan" card with no retry timer at all — it never
  re-checked the schedule and never recovered without a reload.

showStatus now rebuilds the element if it is missing rather than bailing, so the message the caller
asked for is actually displayed and the recovery path continues.

Verified in headless Chrome against the real player: destroy the overlay exactly as the suspended
branch does, then call showStatus — no throw, no uncaught page error, and "Waiting for content..."
on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:08:06 -05:00
Claude 64a6bfd860 Web player: notice when the layout changes, not just when the items do
Editing a layout did nothing on a web screen that was already showing one. Add a zone, move an item
between zones, resize a zone, switch layouts, clear the layout — all silent, for as long as the item
list itself stayed the same.

Two reasons, and both had to be fixed:

- The change fingerprint covered item identity, order, revision, schedules and transition, but not
  zone_id — so moving an item from one zone to another produced a byte-identical fingerprint
  (published_snapshot is ordered by sort_order, so the order did not move either).
- The layout is not part of the item list at all, so a change to it could never appear in an
  item-derived fingerprint. `layout` was assigned and then the function returned "Playlist
  unchanged", and in multi-zone mode nothing else re-renders: each zone runs its own timers and
  renderContent is never called again. The no-change health check does not help either, because the
  old zone divs still hold media so the surface looks attached.

zone_id now sits in the item fingerprint, and the layout gets its own signature covering the layout
id and every zone's geometry, stacking, type and fit. Tizen's ZoneRenderer has always compared a
zone signature — this is the web equivalent, and it is the same defect that was fixed on Android
this week.

Verified in headless Chrome against the real player: a third zone added IN PLACE (same layout id,
same item list, no reload, no restart) re-rendered the screen to three zones. Before the change that
update was discarded as unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:06:23 -05:00
Claude d580994bb3 Carry a widget's revision into the multi-zone path on Android
The widget-refresh work covered the fullscreen path only, so editing a widget placed in a ZONE still
never reached the screen. Two independent gaps, both of which had to close:

- The zone render URL was built from the widget id alone, with no rev, so even a forced re-render
  fetched a URL the WebView had already seen.
- The decision to re-render zones at all keys on an assignment signature of
  content_id:zone_id:widget_id. A widget's identity does not change when it is edited, so the
  signature was byte-identical and the branch fell through to "Multi-zone unchanged, skipping".

A zone holding a single widget never rotates either, so nothing else would have reloaded it. The
customer edited a widget, the dashboard showed the new content, and that region of the screen kept
the old version until the layout geometry changed or the app was force-stopped.

The server has supplied widget_rev on every assignment since the fullscreen fix; both the fullscreen
Android path and the web player's zone path already used it. This is the path that was missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:04:10 -05:00
Claude acadb4c1f4 Stop the playlist and the OTA checker when the Activity is destroyed
onDestroy already shuts down the wall and group controllers, and its comment says exactly why: those
Handlers are on the main looper, which outlives the Activity, so a surviving tick "would keep
broadcasting sync frames against the released player forever". Three other things on that same looper
were never stopped.

PlaylistController kept advancing after the Activity was gone. Every tick wrote the resume index and
emitted play_start/play_end through the still-live WebSocketService, so after any relaunch — the
"launch" command, Relauncher after OTA or boot, a re-pair, or a config change outside the ones the
manifest handles — two controllers were reporting playback for one screen. That inflates Total Plays
and Hours in Reports for that panel, and races over the resume position #234 depends on. Widget items
also re-entered showWidget on a WebView nobody owned any more.

UpdateChecker was never stopped either, and its install receiver was never unregistered:
installReceiverRegistered is per-instance, so each recreate added another checker polling
/api/update/check and another receiver for INSTALL_COMPLETE. N of those turns one
STATUS_PENDING_USER_ACTION into N confirm dialogs stacked over customer content, and concurrent
checkers race in tryPackageInstaller — which starts by abandoning ALL of the app's installer
sessions, so one can abandon another's staged session mid-flight and the update never completes.
shutdown() now does both, and the receiver is held so it can actually be unregistered.

The Activity's own posted callbacks (the 30s failure-check loop among them) are cleared too.

134 Android JVM tests green. The effect is a leak and a duplicate reporting stream rather than a
wrong value on a screen, so it is verified by reading the lifecycle rather than by a unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:03:19 -05:00
Claude 6e9a1f9711 Drop an image decode that finished after the screen moved on
A remote image is decoded on a background thread and mounted on the main thread, and it was mounted
unconditionally — nothing checked it was still wanted.

ImageLoader allows 10s connect plus 30s read, against a slot that is typically 10s, so a slow or
briefly unreachable host finished long after the playlist had advanced and painted itself over
whatever was playing. When that was a video the mount also called exoPlayer.stop(), which lands in
STATE_IDLE — and the advance listener only fires onVideoComplete on STATE_ENDED or a playback error.
Nothing scheduled the next item, so the playlist stopped permanently. The routine refresh could not
rescue it: the playlist signature was unchanged, so the update returned early, and content was still
on screen so nothing looked wrong from the server's side.

The failure branch had the same shape more mildly — onImageError posts next(), cutting short
whatever had since started playing.

Every path that takes the screen now bumps a generation, and a decode applies only if the value it
captured is still current. PipOverlay.loadImageInto has always carried this token; the fullscreen
path was the one place a background result was applied with no staleness check.

4 tests over the guard, kept as pure arithmetic so they need no Android runtime, including that only
the latest of several queued decodes wins and that the error branch is gated too. 134 Android JVM
tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:01:37 -05:00
Claude 88e4a9eb49 Respond to a server rejection once, and stop destroying the cache over a transient one
onUnpaired was assigned twice in setupServiceCallbacks. The later assignment silently replaced the
first, so the handler added earlier this week to surface WHY the server refused a device — the one
whose comment says "Only ProvisioningActivity ever assigned onUnpaired, and it is gone by the time
playback is running" — could never run. Thirty lines below it, something else was assigning exactly
that.

What actually executed cleared the offline playlist cache and jumped to the pairing screen on EVERY
rejection. That is wrong for the case the service is explicitly built to survive: handleServerRejection
parses a settle window, sets awaitingRepair, holds all registration and schedules a single retry, so
a reclaim-settle hold recovers on its own within the window. Tearing the player down over it cost the
panel the cache it would have replayed from and forced a full re-download after re-pairing — the
opposite of what the hold is for.

The two are now one handler. It always surfaces the server's reason, and only navigates to
provisioning when the rejection is terminal and not a block:

  transient  the service recovers by itself; show the reason and stay put
  blocked    a block deliberately survives a re-pair, so the pairing screen cannot resolve it
  terminal   the device really is gone and the operator needs the code

The cache is kept in every case. It is what lets a screen keep showing content while someone walks
over to re-pair it, and re-pairing restores the settings anyway. The service now exposes whether a
rejection carried a settle window, since only it can know.

4 tests over the decision, kept pure so it needs no Activity. 130 Android JVM tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:59:49 -05:00
Claude 9c6b80c411 Apply a saved device snapshot only inside the workspace it was taken in
Per-device settings are saved against the hardware fingerprint so a panel that is deleted and paired
again comes back configured — name, orientation, playlist, blocked flag — without anyone visiting
it. That is deliberate and worth keeping.

A fingerprint is hardware-derived, so the same physical panel presents the same one whoever pairs
it. applyToDevice looked the snapshot up on fingerprint alone with no workspace comparison, and its
per-field guards only check that the referenced row still EXISTS, never who it belongs to:

    if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))

So a screen removed from one workspace and paired into another inherited the first workspace's
playlist and displayed its content, and `blocked` crossed the same way — a device arriving blocked
with nothing the new owner could see to explain it. The manual restore route already compares
workspaces before calling this, so the automatic re-pair path was the only place the check was
missing.

A mismatch is a quiet no-op rather than an error: re-pairing a second-hand panel into a different
workspace is a legitimate thing to do, it just must not carry the previous configuration along. A
snapshot with no workspace recorded still applies, so rows predating the column keep working.

5 tests: neither playlist nor block crosses, a mismatch does not throw, restore still works in full
inside the owning workspace (including a genuine block surviving a re-pair), and legacy rows are
unaffected. 882 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:56:59 -05:00
Claude 14367af5f1 Keep a workspace on schedules that outlive their device group
Deleting a device group converts its group schedules into per-device ones so the screens keep their
programming. That INSERT omitted workspace_id, which is nullable with no default, so every converted
row landed with workspace_id = NULL.

A null workspace does not merely look untidy — it makes the row unreachable in three directions at
once, and they compound into the worst possible combination:

  invisible   the schedule list and the all-screens calendar both filter on workspace_id
  undeletable PUT and DELETE refuse a row with no workspace (403)
  still live  services/scheduler.js has no workspace filter, so it keeps firing every 60 seconds

"I deleted the group but the screens still switch content at 9am, and there is nothing in the
calendar to remove." The only way out was direct database access.

The conversion now carries the workspace, preferring the schedule's own and falling back to the
group's so a legacy group schedule that itself predates workspace_id still converts into a reachable
row. A boot migration repairs rows already orphaned in the field by recovering the workspace from
the device each one targets; anything still unresolvable is left alone rather than guessed at.

4 tests: the converted row keeps its workspace, is visible to the query the list and calendar use,
preserves the actual programming rather than just the ownership, and the repair recovers a row
orphaned before this fix existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:53:21 -05:00
Claude 9958c7c7be Save a layout by diffing its zones, not by deleting and re-inserting them
Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data across the
whole workspace, and returned 200.

The handler deleted every zone and re-inserted the same ids. Its comment claimed that was safe —
"Reuse each zone's id when supplied so device->zone assignments survive an edit (a fresh uuid per
save would orphan them)" — but reusing the id does not help, because SQLite runs the referential
actions on the DELETE and re-inserting the same primary key afterwards resurrects nothing. Two
things point at those rows:

  playlist_items.zone_id  ON DELETE SET NULL  -> every multi-zone playlist item un-assigned, so
                                                 those playlists silently fell back to fullscreen
  schedules.zone_id       ON DELETE CASCADE   -> every zone-bound schedule permanently deleted

No warning, no undo, and nothing in the UI to suggest a geometry tweak had touched schedules at all.

Zones are now updated in place, inserted when new, and deleted only when the editor actually removed
them. An update touches no foreign key, so nothing pointing at a surviving zone is affected. The
cascades are left exactly as they are: on a genuinely removed zone they are the correct behaviour,
and the tests pin that too.

4 tests: a moved zone keeps item assignments and zone-bound schedules, the geometry change is really
applied, adding a zone disturbs nothing, and removing a zone still un-assigns its items and removes
its schedules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:51:28 -05:00
Claude c393cf8ab3 Hold overlay pushes to the same write check as every other fleet action
A PiP overlay renders across a live screen — an arbitrary web page, at full resolution, for as long
as the operator wants. That is a fleet-affecting write, but the three routes that perform it carried
only requireScope('full'), which gates API tokens and is a deliberate pass-through for dashboard
sessions. The file's own comment says so ("No-op for JWT sessions"), on the assumption that
something else covered that case. Nothing did.

Every sibling route pairs the two checks — device-groups.js gates POST /:id/command with
`requireScope('full'), requireGroupWrite`. These had only the half that does nothing for a logged-in
user, so a member who is refused on every other device mutation was accepted here.

requireFleetWrite restores the pairing on POST /, POST /clear and DELETE /, resolving the caller's
context against the workspace the same way the rest of the codebase does.

5 tests pin both directions: refused for a read-only member on all three routes and for an
unauthenticated caller, still allowed for a workspace_editor and for an org owner acting into the
workspace (actingAs, whose workspaceRole is null and must not read as a viewer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:46:03 -05:00
Claude 81f5d4f9f3 Stop shrinking hand-written text widgets into illegibility
A person typing font-size:16px into the Text/HTML widget got 0.15vw — 2.8px on a 1080p screen,
1.9px at 1280 wide, smaller again on anything narrower. Not clipped, not hidden: rendered at a size
nobody can read, in the one widget whose entire purpose is hand-written HTML.

renderText converted every px font size to vw (px/108). That conversion exists to rescue LEGACY
Content Designer output, which used to publish absolute sizes as fontSize*10.8 px — dividing by 108
recovers the author's intended size and lets those widgets scale to any screen. Today's designer
emits cqw and no px at all (frontend/js/views/designer.js), so the conversion only ever needed to
apply to that legacy output. It was applied to everything.

Now it runs only on designer-authored markup, identified by its absolutely-positioned elements —
the same signal the dashboard already uses to decide whether a text widget can be reopened in the
designer. Hand-written markup keeps its px exactly as typed, and legacy designer widgets are
unchanged.

Found by looking at the screen. The rendered HTML and the widget URL both looked correct in every
check I ran; only a screenshot showed the text was microscopic.

5 tests covering both directions, including that a hand-written absolutely-positioned element
without the designer's left-first shape keeps its px. Verified on an Android screen: a 60px heading
and 24px body now render at their authored sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:28:28 -05:00
Claude e0bdd3b65c Web player: re-render a widget whose content was edited
The signature fix was necessary but not sufficient, and only a browser showed it. The update arrived
and was applied — the console logged "Playlist changed, updating" and playlist[0].widget_rev held the
NEW revision — but the iframe on screen still carried the old one.

Two guards were swallowing it. Continuity keeps a surviving item playing and deliberately does not
re-render ("Just retarget the index pointer - no re-render, no interrupt"), and identity is
content/widget ID, which does not change when a widget is EDITED. So the edited widget counted as
surviving. And the fallback that would eventually notice does not apply either: a solo widget is
deliberately never re-rendered on a timer, because that would reset a directory board's scroll.

Between them the new revision sat in the playlist, unused, indefinitely.

Now a surviving WIDGET whose rev changed is re-rendered through the buffered swap — which builds the
new iframe hidden and reveals it on load, so it is flash-free by design and this costs nothing
visually. Non-widget items and unedited widgets are untouched, so the continuity behaviour that
guard exists for is intact.

Verified in headless Chrome driving the real player: paired, widget assigned, then edited with no
page reload and no restart. rev 1785460578 -> 1785460589 on the live iframe.

Also caught here: my first attempt called renderItem(), which does not exist — the console.log fired
and the exception ate the rest of the handler, which looked exactly like the fix not working. The
function is renderContent(item).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:17:22 -05:00
Claude 5c6e0325b1 Widget edits reach the web and Tizen players too, and a pinned render can be cached offline
Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they
rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update
survives a change check, and both change checks key on IDENTITY:

  web    content_id|widget_id|remote_url|filepath|filename|schedules|transition
  tizen  [content_id, widget_id, remote_url, mime_type, schedules, transition]

A widget's identity does not change when it is edited, so an edit produced an identical signature,
the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both,
alongside schedules and transition, which are there for exactly this reason.

The render URL carries the rev on both players as well. In the zone path the web player was picking
up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every
zone; it now reads the zone assignment's own rev.

Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL
carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL
changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for
a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18).

That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could
never hold, so a display that lost its uplink lost its widgets — while its images and video kept
playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected:
they still get no-store, because without a rev nothing distinguishes one render from the next.

Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 859 server
tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:09:06 -05:00
Claude 6e3be7a95a Include a widget's revision in the playlist change signature
The widget-refresh fix did not work, and only the emulator showed it.

widget_rev reached the device correctly and the render URL was built from it correctly, but the
controller de-duped the update before any of that mattered: sig() keys on content/widget IDENTITY,
and a widget's identity does not change when it is edited. The payload was byte-identical, the
update was discarded, the old items were kept — including the old rev — so the URL never changed and
the WebView reuse held. Measured: the player sat on rev=1785459552 for three full cycles after an
edit, logging "Widget already showing, not reloading" each time.

Adding widgetRev to the signature is the same move already made for muted (#129), schedules
(#74/#75) and transitions — all cases where an edit changes playback without changing identity.

Re-verified on the emulator, app left running:
  edited   -> "Showing widget: ...&rev=1785459720" (reload, new rev, no restart)
  unedited -> 3 x "already showing", 0 reloads over 45s, so the anti-flash reuse is intact

Worth recording: the code read correct on all three previous passes. Only running it exposed this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:03:32 -05:00
Claude cad19abee1 Push layout edits to displays, and let a layout be renamed
Editing a layout notified nothing at all — no push to the displays using it — so a zone change
waited for the next heartbeat refresh at best. Combined with the Android rebuild being keyed on the
layout ID (which does not change when you edit a layout in place), that is why adding a fourth zone
took a force-stop to appear. The player-side fix makes the rebuild happen; this makes it prompt.

Renaming: duplicating a template produces "<template> (Copy)" and there was nowhere to change it.
The server has always accepted a name on PUT /layouts/:id; no UI ever sent one. The only name field
in the editor belongs to the selected ZONE, which is easy to mistake for the layout's own — zones
could always be renamed, layouts never could. The heading is now an input and its value rides along
with the Save the user already presses.

Verified on an Android 12 emulator, app left running throughout:
  3-zone layout assigned      -> "Multi-zone layout with 3 zones (was=null)"
  4th zone added in place     -> "Multi-zone layout with 4 zones (layout=a96c39ab, was=a96c39ab)"
The ids match, so the old id-only condition would have skipped the rebuild entirely. Applied ~1s
after the PUT, with no restart and no force-stop.

Also verified the background-audio fix on the same device: 1 started audio player with the video in
the foreground, 0 once another app was brought to the front. (First attempt was invalid — HOME
re-shows this player because it is the default launcher, so it never backgrounds.)

859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:58:21 -05:00
Claude abdb3b434d Silence a backgrounded player, and rebuild zones when a layout is edited in place
Two more from #234, both Android-only.

1. "I closed the app and I can still hear the sound." Nothing in the Android lifecycle pauses a
   WebView, and MainActivity had no onStop at all, so a YouTube embed kept playing with the app in
   the background and the panel kept making noise with the app apparently closed. onStop rather
   than onPause: onPause also fires for a transient dialog or a permission prompt, and pausing
   playback for those would be a visible stutter on a wall. Pauses via the IFrame-API bridge that
   already exists for live mute, so returning to the foreground resumes in place instead of
   restarting the clip.

2. "I added 4 zones and they dont appear on the screen. I had 3 zones before and they appeared."
   The zone rebuild fired only when the layout ID changed. Editing a layout in place keeps its id,
   so setupZones never ran: the geometry stayed at three zones and only the assignments
   re-rendered into the old ones, which is why it took a force-stop to appear. The rebuild now also
   triggers on a signature of the zones themselves (id, position, size, z-index, type, fit).

Compiles clean; NOT yet verified on hardware — both need a device to prove, unlike the audio-on-
item-switch fix which was measured before and after on an emulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:48:31 -05:00
Claude 4cc750ba3a Text widgets: stop losing text off the bottom, and show an edit without an app restart
Two separate faults in the same widget, both reported on #234.

1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document
   with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to
   bottom and disappears. It dont fit."

   The content now gets a wrapper and an overflow mode:
     fit    (default) shrink until it fits — a NO-OP when the content already fits, so it rescues
            widgets that are currently losing text without changing ones that are fine
     scroll pan through it on a loop with a pause at each end, for content genuinely longer than a
            screen where shrinking would make it unreadable
     clip   the old behaviour, kept because a designer-positioned layout may deliberately run past
            the edge and must not be rescaled underneath its author

   Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone
   changes the answer, and fonts arriving late is the classic cause of a fit computed against the
   wrong height.

2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves
   live config, but the player deliberately keeps a widget's WebView while its URL is unchanged
   (re-navigating every duration is a visible flash and destroys widget state — a half-typed
   directory search, scroll position). Editing changes the content, not the id, so the URL never
   changed and the reuse check always hit.

   The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so
   the URL differs exactly when the content differs — and only then, so the anti-flash reuse still
   holds for untouched widgets. The rev is refreshed at send time rather than read from the
   published snapshot, because a widget edit does not republish the playlist. Editing a widget also
   now pushes to the displays showing it, instead of notifying nothing at all.

859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:44:12 -05:00
Claude 452c286357 Stop a YouTube embed when the playlist moves off it
The video kept playing behind the next item and its audio carried on over the top: "even when the
picture is there the sound from the video continues playing."

Switching away only set the WebView's visibility to GONE, and visibility is not playback state — a
hidden WebView keeps running. The three paths that leave a YouTube item (image mount, local video,
streamed video) all hid it and none stopped it. stop() has always blanked the WebView with
about:blank; the item-switch paths simply never did.

This could not surface before 1.9.26, because a YouTube item never advanced at all, so nothing ever
switched away from one. Fixing the advance is what exposed it.

The reporter narrowed it further without being asked, and their finding names the mechanism exactly:
"picture, video -> the sound continues when the picture comes after the video. picture, video,
html/text -> the sound do not play after the video." A widget loads a new URL into the SAME WebView,
which replaces the YouTube page and stops it; an image only hides it. One case was silent and the
other was not for precisely that reason.

stopYoutubeIfPlaying() is guarded on the OUTGOING type, so it must be called before currentType is
reassigned, and it cannot blank a widget that is being reused. Blanking is safe because playYoutube
reloads the embed from scratch on every play.

Verified on an Android 12 emulator, counting the app's own started audio players against the item on
screen, before and after:
  1.9.27 as released — image on screen, 1 player still started (the reported fault)
  with this fix      — image on screen, 0 players started; 1 only while the video is up

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:33:15 -05:00
ScreenTinker 752f39ea43 chore(release): v1.9.27 2026-07-30 19:16:55 -05:00
Claude f7bf9412e4 docs(changelog): 1.9.27 — beta APK channel with a working switch back 2026-07-30 19:16:48 -05:00
ScreenTinker 6d33d00cd0 Merge feat/ota-two-channel: serve a beta APK alongside stable, with a real switch back 2026-07-30 19:13:51 -05:00
Claude b44f9d4f03 Serve a beta APK alongside the stable one, and let a display move between them
1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one
APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on
every display. This makes it a real channel.

- apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with
  ota_beta = 1.
- A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. The server cannot
  infer it — stable's version is the server's own constant because the two ship together, and
  reading it from the APK means parsing binary AndroidManifest.xml on the request path. If the
  sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep
  getting stable. Failing closed matters: advertising a version that does not match the bytes served
  is the OTA-loop condition this fleet has been bitten by before.
- The check and the download resolve the channel identically and fall back to stable identically, so
  apk_size always describes the bytes actually delivered. No APK change was needed — the client
  already fetches whatever download_url it is handed, so displays in the field can be moved between
  channels from the dashboard today.

Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary
"never offer a downgrade" rule stranded the display and unticking the box would have been another
silent no-op. The first attempt returned any non-opted-in display running a pre-release — which
broke a #144 test, correctly: that would have dragged every existing pre-release tester back to
stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return
now requires evidence we actually served that display the beta channel (devices.ota_channel_served,
written once on change, not per check). A tester ahead of the server on their own build is left
alone exactly as before.

Documented in the README, including the constraint that makes the switch-back physically possible:
beta builds must carry a versionCode no higher than the stable they branch from, because Android
refuses to install a lower one. Equal numbers install in both directions.

Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta
serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates
the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date /
channel-return in order. 859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:12:46 -05:00
ScreenTinker d70764991e chore(release): v1.9.26 2026-07-30 18:43:35 -05:00
Claude ac68e95b2b docs(changelog): 1.9.26 — YouTube advance, clearable playlists, pre-release opt-in 2026-07-30 18:43:27 -05:00
Claude dd7596a674 Merge docs/readme-catchup: README catch-up and CHANGELOG backfill
# Conflicts:
#	scripts/bump-version.sh
2026-07-30 18:39:56 -05:00
ScreenTinker 234bff795d Merge docs/api-device-network-fields: document device network fields, pin the spec version 2026-07-30 18:38:06 -05:00
ScreenTinker 2bf8b4271f Merge fix/youtube-never-advances: YouTube items advance, playlists can be cleared, per-display beta opt-in 2026-07-30 18:38:01 -05:00
Claude 301c76c3f7 Let a display opt in to pre-release builds, so a test build is not reverted under the tester
Handing someone a test build was a trap. A prerelease sorts BELOW its own release — 1.9.25-fix234d
is semver-older than 1.9.25 — so a sideloaded display asked "anything newer?", was correctly told
yes, and updated itself straight back off the build we had asked someone to test. Same versionCode,
so Android installed it without complaint. Silent, and within minutes.

That is what happened on #234: the reporter installed the fix, tested for an evening, and reported
nothing had changed. They were right. Their tablet was running the old code again by then, and I had
told them it was fixed without ever checking what the device reported.

Adds a per-display opt-in (devices.ota_beta, default 0, checkbox next to the OTA toggle). When set,
the display keeps a prerelease of the CURRENT core instead of being pulled back to its release.

Deliberately narrow in one direction and deliberately wide in the other:

- Narrow: it only holds a prerelease of the core already installed. A plain release, a -patchN
  build, an upgrade to a newer core, and a display ahead of the server all behave exactly as before,
  and the flag defaults off so a fleet that never sets it is unaffected.
- Wide: an opted-in display is exempted from the superseded-prerelease guard. That guard would
  otherwise pin a tester on an old test build permanently — an older-core prerelease is never
  offered anything, so they would have to notice and sideload their way out. Writing the test is
  what surfaced that; opting in must never mean never updating again.

9 tests covering both directions, including that shipping a newer release pulls a beta display back
onto the release line. 845 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 18:35:31 -05:00
Claude 5297f091af Let a display's playlist actually be cleared
"No playlist" was an option you could select that did nothing. The picker offered it, and the change
handler opened with `if (!newPlaylistId) return; // Don't allow deselecting for now` — so choosing it
sent no request, changed nothing, and said nothing. The guard was honest about why: there was no way
to do it. PUT /devices/:id has never read playlist_id (200, ignored), and POST /playlists/:id/assign
can only ever set one.

Reported on #234 as "I also selected No playlist ... it still showed the same video". It did, and my
first explanation blamed the playlist-swap deferral. The deferral would have stranded it too — that
is fixed separately and tested — but on this path nothing was ever sent, so the deferral never got
the chance.

DELETE /api/devices/:id/playlist, device-scoped rather than playlist-scoped because there is no
playlist to authorize against when clearing. Ownership goes through checkDeviceOwnership like every
other device mutation, so a viewer and a stranger are refused. Clearing an already-clear display is
a no-op success, since it lives in a dropdown someone can pick twice. The now-empty playlist is
pushed to the device so the screen stops, rather than leaving the old content up until something
else happens to refresh it.

Validated on an Android 12 emulator against the reporter's shape: cleared while a YouTube item was
on screen, zero plays afterwards, device row cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 18:29:18 -05:00
Claude 3b23600d97 docs(changelog): backfill 1.9.3 through 1.9.25
The changelog stopped at 1.9.2-patch2, so 23 shipped releases had no entry — including the whole
transition engine, group sync, the device-owner foundation, the hardening pass and every #234 fix.
Anyone deciding whether to upgrade, or working out which release changed a behaviour, had nothing to
read between 1.9.2 and now.

Written from the actual commit ranges between tags rather than from memory, and pitched at the
question a reader has ("do I need this, and what will change") rather than as a commit dump. Detail
scales with the release: 1.9.5 (group sync, device-owner foundation, agency folders) and 1.9.25 get
real explanation; 1.9.9 and 1.9.19 get two lines, because that is what they were.

The 1.9.16 hardening entry describes each fix in the same neutral terms as its commit — the
invariant restored, not the weakness. This is a public repository, some findings from that review
are still open, and exploitation detail helps nobody deciding whether to upgrade. The advice there
is just "upgrade".

Also adds a CHANGELOG check to bump-version.sh: it warns if the release being cut has no entry.
Deliberately a warning and not generation — a generated changelog is worse than none, since it reads
like documentation while saying nothing. This only stops a release being cut silently without one,
which is how the file fell 23 versions behind.
2026-07-29 22:38:40 -05:00
Claude 0b9d9aff76 docs(readme): catch up on displays, OTA behaviour, plans and the public API
The README had drifted behind several shipped features and, worse, behind a few behaviours that
surprise people in practice. Everything here was verified against the code rather than written from
memory — three claims were wrong on the first pass and are corrected below.

Added:

- **Public REST API.** Scoped tokens, the OpenAPI contract and the browsable reference at /docs were
  not mentioned anywhere in the README despite being a shipped, documented surface.
- **When a display will not update itself.** The three things to check in order, and the retry model
  spelled out because "nothing is happening" is indistinguishable from "it gave up" otherwise:
  flagged for attention after 3 failed installs, still retrying to 40 (cheap — the APK is cached, so
  later attempts pull no bytes), then about one a day indefinitely, cleared by a new version. Plus
  what Force update overrides (back-off, attempt count and the MDM stand-down) and what it cannot
  (invent install permissions).
- **Deleting and re-pairing a display.** Settings are keyed to the hardware, so a re-paired panel
  returns configured — which reads as a bug when the old playlist reappears. Also documents that a
  block deliberately survives re-pair, and that Unblock is the way out (and that before 1.9.25 it
  only cleared half, so a display can still be stuck).
- **Plans and comped accounts.** The platform-admin plan overview, and how an inactive plan runs a
  comped/beta/legacy tier without appearing on the pricing page.
- **Optional location permission** for reporting the Wi-Fi network name, and that permission rows
  stay visible as Manage so grants can be reviewed or revoked.
- **One playlist per display**, and that Scheduling is how you rotate several — the question a
  customer asked this week.
- LAN and WAN addresses in the telemetry feature bullet; BrightSign in Supported Platforms.

Corrected while verifying:

- The API reference is served at /docs, not /api-docs.
- Tizen does NOT self-update; only the Android APK does. The two were wrongly lumped together.
- The admin section is labelled "Subscription Plans".
- The retry description conflated the flag threshold (3) with the attempt cap (40) — different
  numbers doing different jobs.
- BrightSign is listed with the caveat that its HTML widget may not survive the player's reload on
  deploy, rather than as unqualified support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:33:48 -05:00
Claude c483ef34dd docs(api): document a device's WAN/LAN addresses and SSID sentinel, and stop the spec version drifting
The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said
version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json,
android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it
had been frozen since the public API landed and integrators were reading a version identity that no
longer existed.

Spec changes:

- info.version -> 1.9.25.
- Device gains its two network addresses, which are easy to confuse and are now described so they
  cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For
  aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as
  reported by the player, which is the one that reaches a panel on site. local_ip is new; both were
  returned by GET /devices and neither was documented.
- Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram,
  cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web
  player does not report what Android does.
- wifi_ssid's "permission" value is called out as a sentinel, not a network name: Android 10+
  withholds the SSID without a location permission ScreenTinker only requests if an operator opts
  in. An integrator who does not know that renders "permission" to an end user as their Wi-Fi name.

Drift prevention, because a wrong version number is silent and nobody re-reads one they trust:

- bump-version.sh now writes the spec version too, anchored to info.version (operation- and
  schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected).
- Three contract tests: the spec version tracks package.json, the two addresses stay documented
  and distinct, and the SSID sentinel stays explained.

No new endpoints — audited every public router's routes against the spec and all are documented.
830 server tests + the 5 contract tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:26:47 -05:00
Claude 9034478c28 Android: a YouTube item must end on its duration, and clearing a playlist must apply at once
A screen kept showing a YouTube video after its playlist was reassigned, and kept showing it after
"no playlist" was selected. Restarting the app showed the new content immediately, which ruled out
the network, the download and the server payload.

Two faults met:

1. Nothing ever ended a YouTube item. playCurrentItem armed an advance only for images and widgets;
   video/youtube is neither, and it is played by loading an embed into a WebView, which reports no
   completion. playYoutube even took the item's durationSec and never read it. So any playlist
   containing a YouTube item stopped rotating at that item permanently — broader than what was
   reported. The web and Tizen players both already time YouTube off its duration; Android was the
   only player that did not, so this brings it back in line.

2. #157 defers a playlist change when the item on screen is dropped from the new list, applying it at
   the next natural advance. With no advance ever coming, the change was stranded. An EMPTY new list
   went down the same path, so "no playlist" — the one action that should always take effect
   immediately — was deferred too.

Fixed all three layers: video/youtube now ends on a timer (ItemTiming), an empty list is never
deferred (PendingSwap), and a deferral gets a 60s deadline so no future item type that ends on a
callback can strand a swap again. Local and remote video stay off the timer path, where STATE_ENDED
drives them, so clips are not cut short.

The deferral rule and the timing rule are pure seams, tested without a device: 126 Android JVM tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:11:40 -05:00
ScreenTinker 40035533e5 Merge branch 'feat/report-lan-ip-and-optional-ssid'
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-07-29 21:57:59 -05:00
ScreenTinker 275e1683b8 Report the screen's own IP, and make the Wi-Fi name an honest optional
A customer read the device page's IP as their screen's address and reported it as wrong.
It was not wrong, it was a different thing: devices.ip_address is the PUBLIC address the
server sees the connection arrive from. Both are useful — you want the public one to
recognise a site, and the local one to actually reach the panel — so the page now shows
each, labelled.

The player already computed its own address for the connectivity report; it just never
reported it. Read straight off the interfaces, so Ethernet panels get it too, and it needs
no permission. Stored on device_telemetry beside wifi_ssid/wifi_rssi, where the
per-heartbeat network facts already live, rather than as another devices column.

The same customer saw "Unknown" for the Wi-Fi name and assumed it needed device-owner
access. It needs LOCATION: Android 8.1+ returns the literal "<unknown ssid>" to an app
without it. So "Unknown" was us reporting a permission gap as if the network had no name.

The player now distinguishes not-allowed-to-know from genuinely-no-Wi-Fi, and the page says
"Needs location permission" instead of a blank. The permission is declared but NEVER
requested at startup and nothing else uses it — a signage player demanding location to
display a network name is a bad trade. It is an opt-in row on the setup screen, using the
same Enable/Manage pattern, and refusing it changes that one field and nothing else.

Also caught by the test suite, and worth recording: the first version of this dropped the
comma in the device SELECT list ("t.uptime_seconds t.local_ip"), which 500'd the endpoint
and failed seven tests that never mention telemetry. Verified end to end afterwards —
public and local addresses both returned, distinct, from a real request.
2026-07-29 21:57:59 -05:00
ScreenTinker 54f1f62762 Merge branch 'fix/group-drag-actually-moves' 2026-07-29 21:33:56 -05:00
ScreenTinker ead452d9b1 Dragging a screen onto a group now moves it instead of adding it
Reported by a customer with two screens and two groups: dragging a screen from one group
to the other showed a confirmation, changed what the screen was playing, but left the
displays page showing the old group — and a second attempt said it was already in group 2.

All three observations were correct. The drop handler borrowed the Manage modal's
"add it to X too?" confirm, then called addDeviceToGroup and nothing else, then reported
"Moved {name} to {group}". So it asked about adding, claimed to move, and added: the
screen ended up in BOTH groups. The page was not stale, it was accurate — and the retry
was right too, because by then it really was in group 2 as well as group 1.

The screen's content DID change because joining a group syncs the device's playlist to
the group's, which is why it looked half-applied rather than broken.

Drag is a move gesture, so it now removes the other memberships after adding the new one
— add first, so a failure leaves the screen in the group it already had rather than
ungrouped by a half-finished move. A removal that fails warns rather than reporting
success it did not achieve.

The Manage modal is deliberately left alone: its checkboxes are add/remove and its "too?"
wording is accurate there. Multi-group membership is a real feature; it just is not what
dragging means.

Not merely cosmetic: deviceSyncGroup() notes it picks "deterministically if it's somehow
in several", so a screen left in two sync-enabled groups gets an arbitrary one. A
half-completed move leaves synchronised playback ambiguous.

Strings added to the six locales that carry the dashboard set; hi.js has none of them and
falls back to English.
2026-07-29 21:33:56 -05:00
ScreenTinker 3f0db335d2 chore(release): v1.9.25 2026-07-29 20:38:47 -05:00
ScreenTinker 2906e559cb Advance the versionCode baseline past the published test builds
Three prereleases were cut for #234 and handed to the reporter, consuming versionCodes
89 through 93 via VERSION_CODE overrides that were never written back to this file. The
committed default was still 88, so bump-version.sh would have produced 89 for 1.9.25 —
an APK that installs over nothing anyone has been testing, since Android refuses a
lower-or-equal code, and silently so from the user's side.

Set to 93 so the next bump lands on 94, above every published build.

Lesson worth keeping: a VERSION_CODE override for a one-off build leaves this file lying
about where the release line actually is.
2026-07-29 20:38:46 -05:00
ScreenTinker bb016b8313 Merge branch 'feat/admin-plans-with-counts' 2026-07-29 19:36:10 -05:00
ScreenTinker a25c6827a7 Show every plan on the admin tab, with who is on each
The admin plan table read /api/subscription/plans, which filters `active = 1` because
that endpoint feeds the public pricing page. So the one screen meant to show the
operator what plans exist could not show a hidden one — a comped or beta tier was
invisible to us as well as to customers, with no way to see it existed or who was on it.
Found immediately after creating exactly such a plan.

GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number
of accounts, organisations and screens on it. Visible plans sort first so the list still
reads like the pricing ladder, with hidden ones after and badged.

The public endpoint is deliberately untouched: hiding a plan has to keep working, and
the test pins BOTH directions because they pull against each other — the admin list must
include an inactive plan, and the public list must never leak one.

Counts are the point, not decoration: "how many people are on what plan" is the question
you actually ask of this screen, and it was answerable only by hand in SQLite.

Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and
organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so
this should be unreachable — but migrations here do rebuild tables with foreign keys off
(the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be
orphaned. Six lines for a state that would otherwise be silent.

Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup
falls back to English, and four Hindi strings among forty English ones would read worse
than consistent English.
2026-07-29 19:36:10 -05:00
ScreenTinker 10f1dccdc8 Merge branch 'fix/unblock-clears-saved-block' 2026-07-29 18:44:11 -05:00
ScreenTinker 3159f94107 Make unblock stick, and say so when a device is refused
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.

1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.

2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.

(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)

3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.

Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.

Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
2026-07-29 18:44:11 -05:00
ScreenTinker 39c4ec8af8 Merge branch 'fix/setup-permissions-revocable' 2026-07-29 18:27:45 -05:00
ScreenTinker 8eff6d57d1 Let permissions be turned back off from the setup screen
Every row on the setup screen hid its button once the permission was granted
(visibility = GONE), which made each one a one-way door. None of these can be revoked
by the app — they all live in system Settings — so hiding the only route to that screen
removed the way back entirely. Asked on #234: "if I make the app as Home launcher but
later on want to remove it then how can I do it?"

The button now stays and relabels to "Manage", with the same destination. Two rows
needed more than a relabel, because their existing destination was a dead end once
granted:

  - Battery: ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS only ASKS to add an
    exemption and cannot remove one. An already-exempt user now goes to the system
    list (verified: Settings$HighPowerApplicationsActivity).
  - Notifications: requestPermissions() does nothing once the answer has been given.
    Now opens app notification settings, which toggles either way.

Also fixes the launcher row disagreeing with itself. The status read
resolveActivity(MATCH_DEFAULT_ONLY), which can name us for merely being a HOME
candidate, while the button asked RoleManager. So the row could say ON while the OEM
launcher was still home — and the button would then offer to BECOME home rather than
open the picker. That is the other half of the same report: "in the apk I have granted
the permission ... BUT in the settings of the tablet it still shows the tablet native
launcher as home." Status and action now ask the same authority.

Verified on an Android 12 tablet, both directions: not-home reads OFF/Set; after
becoming home it reads ON/Manage and Manage opens the Home-app picker (DefaultAppActivity)
— a way out, which is what was asked for.

NOTE: this screen's strings are hardcoded English in the layout and in code ("ON",
"OFF", "Enable", "Continue Anyway"), so "Manage" matches what is already there rather
than introducing one translated word among twenty untranslated ones. Localising the
screen is worth doing and is deliberately not mixed into this change.
2026-07-29 18:27:45 -05:00
ScreenTinker b09bed645d Merge branch 'fix/provisioning-callback-relaunch-loop' 2026-07-29 18:01:42 -05:00
ScreenTinker 83c9bc5aa6 Clear ProvisioningActivity's service callbacks (the white-flash relaunch loop)
Reported on #234 as a screen that flashes white "over and over", unkillable — "there
is nothing we can do on the tablet". It is a leaked listener.

ProvisioningActivity installs onRegistered/onUnpaired/onPaired on WebSocketService and
then finish()es. The service outlives it and nothing ever clears them: MainActivity
assigns neither of those three, so nothing overwrites them either. onPaired therefore
stays wired to a destroyed Activity for the life of the process — keeping it alive, and
still firing.

And it fires often. The server sends device:paired on EVERY register, not only the
first. So: register -> paired -> the stale callback starts MainActivity with
CLEAR_TASK -> new Activity binds and registers -> paired -> again. Measured on an
Android 12 tablet with a bare paired device and nothing assigned: 240 activity starts
in 180 seconds, about 1.3 a second, indefinitely.

Android 12 is where it becomes intolerable rather than merely wasteful: every launch
draws a splash screen there, so each iteration is a visible white flash. The same loop
on Android 9 has no splash and reads as an occasional glitch — which is why it was
originally dismissed as unreproducible after a clean reinstall. A clean reinstall
starts MainActivity directly and never runs ProvisioningActivity, so the callback is
never installed and the loop never begins. Pairing is what arms it.

onPaired is now one-shot — the hand-off to MainActivity is all it was ever for — and
all three are dropped in onDestroy too, which covers backing out before pairing
completes.

Same device, same pairing flow, 180s: 240 activity starts and 240 splash screens
before, 0 and 0 after, with registrations falling from 240 to 2.

⚠️ No other callback is ever nulled either (there are ~20). MainActivity's are
overwritten by the next MainActivity so they self-heal, but each one leaks the previous
Activity until then. Worth a sweep; this commit fixes only the three that never get
overwritten.
2026-07-29 18:01:34 -05:00
ScreenTinker ce626c7e8e Merge branch 'fix/playlist-refresh-not-once-per-item'
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-07-29 14:13:42 -05:00
ScreenTinker bc00bc1eb1 Stop re-registering the device once per playlist item
PlaylistController.next() asks for a playlist refresh on every item advance, and
requestPlaylistRefresh() emits a full device:register. The server's register handler
runs 7+ statements plus the identity/fingerprint path and rebuilds the playlist
payload, then pushes the whole playlist back down. So a panel showing a 10-second
image re-registered six times a minute, indefinitely, and each reply fed a fresh
playlist into a controller that had to diff it — which is what kept the #234 restart
loop supplied.

It was buying nothing. The heartbeat already refreshes every 4th beat (60s), so the
periodic pull this duplicated happens either way.

Throttled at the single chokepoint rather than by editing callers, because the callers
have genuinely different intents — network-came-back, service-connected, per-item, and
the heartbeat itself — and ranking them would be guesswork. A shared floor keeps every
caller's meaning: recovery paths still refresh, they just cannot stack. The window sits
just under the heartbeat's own 60s so the two interleave instead of the throttle
systematically eating the pull we are relying on.

Measured on the reproduction over 240s: 9 registrations for 9 item plays before, 3 for
the same 9 plays after, with playback unchanged. The saving scales with how short the
items are — a 10s item goes from six refreshes a minute to about one.

Does NOT change what a refresh does, only how often one may be asked for.
2026-07-29 14:13:42 -05:00
ScreenTinker 3a681abda0 Merge branch 'fix/resume-playlist-position-across-recreate' 2026-07-29 09:13:30 -05:00
ScreenTinker 66d9dc7fef Resume the playlist where it was after an Activity rebuild (#234)
Reported as "if there are 2 pictures or one picture and one video only one plays",
and the reporter had never once seen the second item.

PlaylistController is constructed with MainActivity, so every rebuild gives it a fresh,
empty instance. The playlist then arrives — from the disk cache or the socket, it does
not matter which — and the controller sees "0 -> N items", treats it as a first load,
and starts at the top. Anything the panel does that recreates the Activity therefore
sends playback back to item 1.

That would be survivable if it happened rarely. On the reproduction it happened at
every item boundary: the device re-registers, the app relaunches itself with
NEW_TASK|CLEAR_TOP, onCreate runs, and playback restarts. The second item was on
screen for 135ms each cycle, which is why it read as "only one plays" rather than as
a glitch. Prod play_logs agree: the second item logging 0-1s durations while the first
accumulated every real second of playtime, on two unrelated customer devices.

Position now lives in ServerConfig, outside the object that keeps being rebuilt, and
start() resumes from it when the save is recent. A cold start, a stale save, a
shrunken playlist, a missing save, or a clock that jumped backwards all fall back to
starting at the top, so genuine first-runs are untouched.

This does NOT address why the panel relaunches itself once per item — that is the
noisier half and wants its own change. It does mean a relaunch costs a restarted item
instead of a playlist that can never advance.

Reproduced first, on an Android 9 emulator with the reporter's exact shape (12MP
portrait JPEG + 40s MP4): image 135ms before, a full 10.05s after, with the video
holding its 40.1s, over four clean cycles.
2026-07-29 09:13:30 -05:00
269 changed files with 37540 additions and 1345 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

@ -79,6 +79,21 @@ jobs:
cp tizen/ScreenTinker.wgt ScreenTinker.wgt
ls -la ScreenTinker.wgt
- name: Build BrightSign autorun.zip (single-file player installer)
run: |
chmod +x scripts/build-autorun-zip.sh
./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"
@ -87,22 +102,49 @@ jobs:
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
--exclude='*.apk' \
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt brightsign
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
ls -la "$OUT"
- 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"
@ -111,12 +153,17 @@ jobs:
echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes"
echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher"
echo " at \`https://<your-instance>/player\` (no signing needed)."
echo "- \`autorun.zip\` - BrightSign player installer. Drop it on the root of a player's"
echo " storage (microSD, USB, or internal flash) and power-cycle: it unpacks itself and"
echo " reboots into the player. Edit \`screentinker.json\` inside the archive first to"
echo " point it at your own server."
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)."
else
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
@ -133,6 +180,8 @@ jobs:
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
--notes-file RELEASE_NOTES.md \
"${TARBALL}" \
autorun.zip \
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
tizen/ScreenTinker.wgt
docker:
@ -160,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

File diff suppressed because it is too large Load diff

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).
@ -39,6 +46,12 @@ COPY VERSION /app/VERSION
COPY docs/openapi.yaml /app/docs/openapi.yaml
# database.js requires scripts/migrate-multitenancy at boot
COPY scripts/ /app/scripts/
# The BrightSign bridge and sync modules are served to the player from ../brightsign so the copy
# the player loads can never drift from the one on the player's own storage. That RUNTIME path
# does not exist unless the directory is in the image: without this the routes 404 in a container
# while working perfectly from a dev checkout — and a missing player asset fails silently, because
# the SPA fallback answers 200 with HTML where JavaScript was expected.
COPY brightsign/ /app/brightsign/
VOLUME ["/data"]
EXPOSE 3001
CMD ["node", "server.js"]

454
README.md
View file

@ -32,7 +32,7 @@ ScreenTinker is a free, open-source **digital signage CMS** you can self-host on
- **Widgets** — clocks, weather, RSS tickers, text/HTML, webpages, social feeds, and Directory Board (scrolling lobby tenant/room/staff directories with dark/light themes, category management, and anti-burn-in motion)
- **Kiosk mode** — interactive touchscreen interfaces
- **Proof-of-play** — per-content and per-device analytics, hourly/daily breakdowns, CSV export for ad verification
- **Device telemetry** — battery, storage, RAM, CPU, WiFi signal strength, and uptime reported by Android players
- **Device telemetry** — battery, storage, RAM, CPU, Wi-Fi signal strength and uptime reported by the players, plus both of a display's addresses: its **local (LAN) IP** as the player sees itself, and the public/WAN address the server saw it connect from. Wi-Fi network name is included where the platform allows it (Android 10+ needs an opt-in location permission — see Device Setup)
- **Offline resilience** — both web and Android players keep displaying cached content during server or internet outages (Android ContentCache, web player Service Worker); state syncs when connectivity returns
- **Mobile-responsive** — full management dashboard and landing page work on phones and tablets
- **Workspaces** — multi-tenant data model: organizations contain workspaces, workspaces contain devices/content/playlists/schedules; users can be members of multiple workspaces and switch via a dropdown in the sidebar
@ -46,6 +46,7 @@ ScreenTinker is a free, open-source **digital signage CMS** you can self-host on
- **Security** — JWT auth, bcrypt hashing, parameterized SQL, rate-limited endpoints, per-user ownership checks on all resources, ongoing auth/IDOR/XSS audits
- **Built-in billing** — Stripe integration for SaaS subscriptions (optional)
- **Auto-update** — OTA updates pushed to devices automatically
- **Public REST API** — scoped personal access tokens (`read` / `write` / `full`) over the same resources the dashboard uses, workspace-confined by construction. Documented as an OpenAPI 3.1 contract ([`docs/openapi.yaml`](docs/openapi.yaml)) and browsable on any instance at `/docs` (served locally, no CDN, so it works air-gapped)
- **Activity log** — full audit trail of user and system actions
## Architecture
@ -92,7 +93,19 @@ Schema migrations run automatically the first time the server starts after a git
## Supported Platforms
Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser.
Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, BrightSign, and any
device with a web browser.
Anything with a reasonably modern browser can be a display without installing anything: point it at
`/player`. The native players add what a browser cannot: the **Android APK** gives you unattended boot,
OTA self-update, remote power and touch injection, and a content cache that survives a reboot; the
**Tizen `.wgt`** gives you an installed app that launches itself on the TV. Tizen does not
self-update — new versions are installed the same way the first one was.
> **BrightSign** runs the unmodified browser player (verified on Series 5 / Chromium 120) and needs
> no separate build. One caveat worth knowing before you rely on it: BrightSign's HTML widget does
> not always survive the page reload the player performs when you deploy new content, and may need a
> restart to come back. Treat it as working but less hands-off than the native players.
## Self-Hosting
@ -101,6 +114,11 @@ Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, a
- 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
@ -161,6 +179,75 @@ Two things to know before enabling it:
- **It is read by the player, not the server**, so only players new enough to understand
`allow_managed` honour it. Older players keep standing down regardless.
#### When a display will not update itself
OTA is per-display and can be turned off per display. If one is not taking an update, the order to
check is:
1. **Is OTA enabled for it?** There is a per-display toggle; a display with it off will never
self-update, by design.
2. **Is it standing down for an MDM?** It reports `manual_update_required` if so — see above.
3. **Has it been retrying and failing?** Retrying and telling you about it are two separate
things, on purpose:
- After **3** failed installs the display **flags itself as needing attention** in the dashboard.
A human is demonstrably required by then, so it says so early rather than at the end.
- It **keeps retrying anyway**, up to 40 attempts. Attempts after the first are close to free —
the APK is downloaded and signature-checked once and then reused from cache, so retry number
twelve pulls no bytes.
- Past that it settles to about **one attempt a day**, indefinitely. It never gives up for good,
and a new version clears the count — so a display stuck for a week still picks up the next
release on its own.
The flag is what to watch for. Silence is not the signal.
**Force update** — per display, or as a group command — deliberately ignores the back-off, the
attempt count *and* the MDM stand-down, and tries straight away. It reports back either way,
including "already up to date", so the button never just appears to do nothing. What it cannot do is
invent permissions: if installs need a confirmation tap on that hardware, forcing still raises the
dialog. It is the right button once you have fixed whatever was breaking the update.
#### Running a beta channel
By default an instance serves one APK to every display, at `/download/apk`. You can publish a second
build alongside it and send it only to displays you choose:
1. Put the beta APK next to the stable one, as **`ScreenTinker-beta.apk`** (same locations as
`ScreenTinker.apk``/data/` in a container, or the install root).
2. Declare its version in a sidecar text file, **`ScreenTinker-beta.apk.version`**, containing just
the version — e.g. `1.9.27-rc1`. This is required. The server cannot read the version out of an
APK cheaply, and advertising a version that does not match the bytes it serves is how update
loops start, so **a beta with no declared version is ignored entirely** and opted-in displays
keep getting the stable build.
3. Tick **Accept pre-release builds** on any display that should receive it.
Untick the box to move a display back to the release build — the server offers it the stable build
even though it is technically "older" than the beta. Displays you never put on the channel are
untouched by any of this.
> **Cut beta builds with the same `versionCode` as the stable release they branch from.** Android
> refuses to install a lower `versionCode`, so a beta numbered above stable can be installed but
> never returned without uninstalling the app (which loses the display's pairing). Equal numbers
> install in both directions, which is what makes switching back work.
#### Deleting and re-pairing a display
A display's settings are keyed to the hardware, not to its row in the database. Delete a display and
pair the same panel again and it comes back with its previous **name, orientation, timezone, notes
and assigned playlist** already set — you do not have to configure it twice, and a panel that is
physically hard to reach does not need a visit. (The playlist only returns if it still exists; a
deleted one is not resurrected.)
Two consequences that are easy to misread:
- The old playlist reappearing is ScreenTinker restoring it, not a bug. If you deleted the display
in order to *clear* it, change the playlist after re-pairing rather than before.
- **A blocked display stays blocked**, deliberately. Blocking is a security control, so it must not
be defeatable by deleting the display and pairing again. Use **Unblock** — that clears the stored
block as well as the live one. (Before 1.9.25, Unblock only cleared the live one and the block came
back on the next re-pair; if you have a display that refuses to pair for no visible reason, unblock
it once on this version.)
#### Raising the upload limit
`MAX_FILE_SIZE` sets what **the application** accepts. It is usually not the only limit, and it
@ -218,31 +305,256 @@ If you want to charge your users, plug in your own Stripe keys. Without them, al
The default plans are: Free (2 devices), Starter (8 devices), Pro (25 devices), and Enterprise (unlimited). Edit the `plans` table to change pricing, limits, or add/remove tiers. In self-hosted mode, the first user gets Enterprise automatically.
#### Google OAuth
#### Plans and comped accounts
Let users sign in with Google.
Platform admins get a plan overview under **Admin → Subscription Plans**: every plan on the instance with how
many accounts, organizations and displays are on each, so you can see what people actually use
before changing a price or retiring a tier. It also flags accounts pointing at a plan that no longer
exists, which otherwise surfaces only as odd entitlement behaviour.
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
A plan marked **inactive** disappears from the customer-facing pricing page but keeps working
normally for anyone already on it. That is how you run a comped, beta or legacy tier without
advertising it — put the account on the hidden plan and it simply gets those limits. The overview
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.
#### Single sign-on (OpenID Connect)
> **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.
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)
@ -318,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:
@ -330,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
@ -404,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:
@ -478,6 +817,24 @@ Locked out? Run this on the server to get a temporary admin token (1 hour):
node scripts/reset-admin.js
```
### Forcing an update on one display
A display whose periodic update checker isn't firing won't pull a new APK on its own, and putting it
on the beta channel alone won't reach it either. This sends the same forced check the dashboard's
force-update button sends — it ignores the backoff cap and the MDM stand-down:
```bash
node scripts/force-update.js --list # displays online right now
node scripts/force-update.js <id-or-prefix> # force a check on one display
node scripts/force-update.js <id> --dry-run # prove auth/handshake, send nothing
```
Run it on the server (it needs the database and `JWT_SECRET`, and mints a short-lived
`platform_admin` token). On a display that isn't device-owner provisioned the install raises a
confirm dialog **over whatever is on screen** and leaves it there until someone accepts, so aim it at
one display when a person can see it. Updating preserves runtime permissions; only uninstalling
clears them.
### Building the Android APK
The Android player app is in the `android/` directory. To build it:
@ -521,13 +878,70 @@ 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.
One of those rows is **optional and off by default**: granting location lets the player report the
**Wi-Fi network name** for the display. Android 10 and later will not reveal the SSID without it.
Nothing else changes if you skip it — the display works identically and still reports signal
strength, and the dashboard says the network name needs that permission rather than showing a blank.
> **One playlist per display, and how to run more.** A display has a single playlist at a time.
> To rotate between several, use **Scheduling** — "Playlist A 9am-5pm, Playlist B evenings", or
> different playlists on different days — and the display switches on its own, offline included,
> once the schedule has reached it.
> **Troubleshooting a player** (stuck on "Connecting to server", re-pointing a
> device to a different server, or connecting adb over Wi-Fi): see
> [docs/android-troubleshooting.md](docs/android-troubleshooting.md).

View file

@ -1 +1 @@
1.9.24
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? ?: "88").toInt()
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.24"
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 {
@ -43,6 +43,14 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
// ScheduleEval uses java.time (Instant/LocalDate/ZoneId), which is API 26 — but minSdk is
// 24. Without desugaring, per-item dayparting/expiry threw NoClassDefFoundError on Android
// 7.0/7.1, which are still common on cheap signage sticks and older TV boxes. Because that
// is an Error and not an Exception, the evaluator's deliberate fail-open guard did not
// catch it: the playlist update aborted before content downloaded, and the cold-start path
// then cleared the playlist cache — so the screen sat on "waiting for content" and a reboot
// did not help.
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@ -63,6 +71,7 @@ android {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
// AndroidX
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
@ -78,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

@ -9,6 +9,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- OPTIONAL, and never requested at startup. Android 8.1+ hides the connected Wi-Fi network
name from apps without location permission, so the device page can only show "unavailable"
without it. A signage player should not demand location to display a network name, so this
is opt-in from the setup screen and nothing else depends on it: not granting it changes
only that one field. Coarse is enough below Android 10; fine is required from 10 onwards. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

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
@ -227,13 +248,17 @@ class MainActivity : AppCompatActivity() {
val cid = item.contentId.ifEmpty { item.widgetId ?: "" }
if (event == "play_start") wsService?.sendPlayStart(cid, item.filename, item.durationSec)
else wsService?.sendPlayEnd(cid, item.filename, completed)
}
},
// #234: carry the playback position across Activity rebuilds. Without this a relaunch
// restarts the playlist at item 1, so anything after it never gets a turn.
loadResume = { config.resumeIndex.takeIf { it >= 0 }?.let { it to config.resumeAt } },
saveResume = { index, atMs -> config.resumeIndex = index; config.resumeAt = atMs }
)
// Screen-resilience: an item is playable only when its content is actually available —
// a widget, a remote stream, or a fully-downloaded local file. A not-yet/failed download is
// skipped (kept in the background) instead of blanking the screen on a loading state.
playlistController.setContentReadyCheck { item ->
item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId)
item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId, item.contentRev)
}
// feat/transition-engine: full-screen GLES2 overlay that plays image/video wipes. Inserted just
@ -366,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
@ -443,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) {
@ -472,25 +551,93 @@ 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() {
// #170: on a fresh network connection, clear stuck download backoff so content that failed
// to download while the link was settling retries on the next sweep (the service also
// requests a playlist refresh). Keeps single-flight; only touches failure/backoff state.
// #234: the server rejecting us (operator block, cleared credentials, reclaim settle) has
// to be VISIBLE. Only ProvisioningActivity ever assigned onUnpaired, and it is gone by the
// time playback is running — so a rejection left the screen sitting on "Connecting to
// server", and the player then blamed the URL. The server always says why; show that.
wsService?.onUnpaired = {
runOnUiThread {
val why = wsService?.lastRejectionReason ?: ""
val blocked = why.contains("block", ignoreCase = true)
val transient = wsService?.lastRejectionTransient == true
Log.w("MainActivity", "server rejected this device ($why, transient=$transient)")
showStatus(
if (blocked) getString(R.string.device_blocked_status)
else getString(R.string.device_unpaired_status)
)
// A TRANSIENT rejection (the reclaim-settle hold: "retry after N seconds") is one
// the service recovers from by itself — it holds, retries once and comes back. Tear
// nothing down for it. The previous handler did the opposite: it wiped the offline
// playlist cache and jumped to provisioning on every rejection, so a self-healing
// hold cost the panel its cache and forced a full re-download after re-pairing.
//
// A terminal rejection means this device really is gone from the server, and the
// operator needs the pairing code, so provisioning is right. The cache is kept
// either way: it is what lets the screen keep showing content while someone walks
// over to re-pair it, and re-pairing restores the settings anyway.
if (!transient && !blocked) {
handler.post {
startActivity(Intent(this@MainActivity, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
// Server-initiated re-pair (known-good URL): show the code, not URL entry.
putExtra("EXTRA_REPAIR", true)
})
finish()
}
}
}
}
wsService?.onNetworkAvailable = {
if (::downloadCoordinator.isInitialized) downloadCoordinator.resetAllBackoff()
}
@ -549,14 +696,30 @@ class MainActivity : AppCompatActivity() {
val currentLayoutId = zoneManager?.currentLayoutId
// Build a signature of current assignments to detect content changes
// widget_rev belongs in here for the same reason it is in the fullscreen playlist
// signature: editing a widget changes its CONTENT, never its id, so without it a
// zone assignment looked identical and the re-render was skipped as "unchanged".
val assignmentSig = (0 until assignments.length()).map { i ->
val a = assignments.getJSONObject(i)
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}"
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}:${a.optLong("widget_rev", 0L)}"
}.sorted().joinToString("|")
val changed = assignmentSig != zoneManager?.lastAssignmentSig
// The ZONES themselves can change without the layout id changing — editing a layout
// in place (adding a 4th zone to a 3-zone layout) keeps the same id. Rebuilding only
// on an id change meant the new zone never appeared: the geometry stayed as it was
// and only the assignments re-rendered into the OLD zones, so the change looked like
// it had been ignored until the app was force-stopped. Reported on #234.
val zoneSig = (0 until layoutZones.length()).map { i ->
val z = layoutZones.getJSONObject(i)
"${z.optString("id")}:${z.optDouble("x_percent", -1.0)}:${z.optDouble("y_percent", -1.0)}:" +
"${z.optDouble("width_percent", -1.0)}:${z.optDouble("height_percent", -1.0)}:" +
"${z.optInt("z_index", 0)}:${z.optString("zone_type")}:${z.optString("fit_mode")}"
}.sorted().joinToString("|")
val zonesChanged = zoneSig != zoneManager?.lastZoneSig
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId || zonesChanged) {
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
handler.post {
hideStatus()
@ -567,6 +730,7 @@ class MainActivity : AppCompatActivity() {
zoneManager?.setupZones(layoutZones, layoutId)
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId)
zoneManager?.lastAssignmentSig = assignmentSig
zoneManager?.lastZoneSig = zoneSig
}
} else if (changed) {
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
@ -609,6 +773,10 @@ class MainActivity : AppCompatActivity() {
val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "")
if (contentId.isEmpty()) continue
val filename = item.optString("filename", "content")
// Bumped when the bytes behind this id change. A cached copy at a different revision is
// a MISS: the id, the filename and the URL are all identical after a replace, so this
// is the only thing that can tell a panel its copy is out of date.
val contentRev = item.optLong("content_rev", 0L)
// org.json's optString(key, null) returns the STRING "null" when the value is JSON
// null (not the fallback) — so a local item with "remote_url": null was being
// misclassified as a remote stream, ack'd "ready", and NEVER downloaded, stranding
@ -628,7 +796,7 @@ class MainActivity : AppCompatActivity() {
// defers when the socket is down (watchdog owns recovery), respects backoff, and
// downloads at most once. It acks ready/failed itself (deduped via onAck).
if (contentChanged) downloadCoordinator.resetBackoff(contentId) // #170: retry now, don't wait out a stale backoff
downloadCoordinator.ensure(contentId, filename)
downloadCoordinator.ensure(contentId, filename, contentRev)
}
// Start/resume playback immediately — do NOT wait on downloads (they're async now).
@ -720,19 +888,39 @@ class MainActivity : AppCompatActivity() {
?: Log.w("MainActivity", "screen_off/lock_now: no owner/admin/accessibility — unsupported")
}
}
// No reliable privileged wake on a non-rooted panel (the old keyevent 224 was denied);
// retired to a logged no-op.
"screen_on" -> Log.w("MainActivity", "screen_on: no privileged wake path on a non-rooted panel — no-op")
// Was a logged no-op: the retired `input keyevent 224` is denied to an app UID, and
// that one failure was read as "no wake path exists". A wake LOCK is a different
// mechanism needing only WAKE_LOCK, which we already hold — so screen_off worked
// and screen_on did not, and an operator who slept a panel overnight had to drive
// out to wake it. Losing the screen is the expensive direction to fail in.
"screen_on" -> {
val woke = systemControl.wakeScreen()
// The wake lock lights the panel; on a locked device the keyguard is still in
// front of the player, so ask for it to be dismissed too. Both are best-effort
// and independent — a device that ignores one may honour the other.
runOnUiThread {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
(getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager)
?.requestDismissKeyguard(this, null)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
)
}
} catch (e: Throwable) { Log.w("MainActivity", "screen_on keyguard: ${e.message}") }
}
Log.i("MainActivity", "screen_on: wake=$woke")
}
// #161 Tier-2 (all no-op off-owner via STPolicy): kiosk lock-task, time/tz, status bar,
// uninstall block. Device owner enters lock-task silently; others get screen-pinning.
"kiosk_lock" -> {
stPolicy().setLockTaskAllowed(true)
try { startLockTask() } catch (e: Throwable) { Log.w("MainActivity", "startLockTask: ${e.message}") }
}
"kiosk_unlock" -> {
try { stopLockTask() } catch (e: Throwable) { Log.w("MainActivity", "stopLockTask: ${e.message}") }
stPolicy().setLockTaskAllowed(false)
}
"kiosk_lock" -> setKioskMode(true)
"kiosk_unlock" -> setKioskMode(false)
"set_time" -> { val ms = payload?.optLong("millis", 0L) ?: 0L; if (ms > 0) stPolicy().setTime(ms) }
"set_timezone" -> { val tz = payload?.optString("timezone", "") ?: ""; if (tz.isNotEmpty()) stPolicy().setTimeZone(tz) }
"status_bar" -> stPolicy().setStatusBarDisabled(payload?.optBoolean("disabled", true) ?: true)
@ -753,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) {
@ -819,19 +1015,6 @@ class MainActivity : AppCompatActivity() {
ackedContent.clear()
}
wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning for re-pair")
config.clearPlaylistCache()
handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
// Tell provisioning this is a server-initiated re-pair (known-good URL) so it
// shows a "waiting for re-pair" status + the code instead of the URL entry.
putExtra("EXTRA_REPAIR", true)
})
finish()
}
}
}
// Root-2 content-ack de-dup. Re-acking content state (SEED-A) fixes the CMS "stuck downloading"
@ -856,8 +1039,11 @@ class MainActivity : AppCompatActivity() {
// layouts; multi-zone widgets go through ZoneManager). Previously unhandled,
// so widgets were blank/broken in default-fullscreen and the fullscreen template.
if (item.isWidget) {
// rev makes the URL change when — and only when — the widget's content changed, so an
// edit reloads while an untouched widget still hits the no-flash reuse path.
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" +
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "")
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "?d=") +
"&rev=${item.widgetRev}"
Log.i("MainActivity", "Playing widget fullscreen: $url")
mediaPlayer.showWidget(url)
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
@ -893,7 +1079,7 @@ class MainActivity : AppCompatActivity() {
val file = contentCache.getCachedFile(item.contentId)
if (file == null) {
Log.i("MainActivity", "Content not ready at play time (${item.filename}) — keeping screen, advancing (bg download continues)")
downloadCoordinator.ensure(item.contentId, item.filename) // ensure it's being fetched (single-flight)
downloadCoordinator.ensure(item.contentId, item.filename, item.contentRev) // ensure it's being fetched (single-flight)
handler.post { playlistController.next() }
return
}
@ -1089,10 +1275,20 @@ class MainActivity : AppCompatActivity() {
getString(R.string.settings_exit)
)
// Requested from the field: with kiosk on, the PIN menu is the ONLY way back out on a
// panel with no other input. Shown only when locked — an entry that does nothing is worse
// than no entry, and this menu is already long.
val kiosk = kioskModeEnabled()
val menu = if (kiosk) items + getString(R.string.settings_exit_kiosk) else items
AlertDialog.Builder(this)
.setTitle(getString(R.string.settings_title))
.setItems(items) { _, which ->
.setItems(menu) { _, which ->
when (which) {
items.size -> { // the appended exit-kiosk entry; only present when locked
setKioskMode(false)
Toast.makeText(this, getString(R.string.settings_exit_kiosk_done), Toast.LENGTH_LONG).show()
}
0 -> showChangeServerDialog(serverUrl)
1 -> {
config.clearDeviceCredentials()
@ -1108,6 +1304,48 @@ class MainActivity : AppCompatActivity() {
.show()
}
/*
* Kiosk lock, remembered.
*
* startLockTask() is a runtime call on the Activity it does not survive a reboot. A panel
* locked from the dashboard came back up unlocked, with nothing to say so, and the only way
* to notice was that someone could suddenly leave the app. Reported from the field.
*
* The flag is written BEFORE the lock is attempted so a device that reboots mid-call still
* comes back in the state the operator asked for; a lock that then fails is retried on the
* next start rather than being forgotten.
*/
private fun setKioskMode(enabled: Boolean) {
try {
getSharedPreferences("screentinker", Context.MODE_PRIVATE)
.edit().putBoolean("kiosk_enabled", enabled).apply()
} catch (e: Throwable) { Log.w("MainActivity", "kiosk pref: ${e.message}") }
if (enabled) {
stPolicy().setLockTaskAllowed(true)
try { startLockTask() } catch (e: Throwable) { Log.w("MainActivity", "startLockTask: ${e.message}") }
} else {
try { stopLockTask() } catch (e: Throwable) { Log.w("MainActivity", "stopLockTask: ${e.message}") }
stPolicy().setLockTaskAllowed(false)
}
}
private fun kioskModeEnabled(): Boolean = try {
getSharedPreferences("screentinker", Context.MODE_PRIVATE).getBoolean("kiosk_enabled", false)
} catch (e: Throwable) { false }
/** Re-enter lock task after a restart, if that is the state the operator left it in. */
private fun restoreKioskMode() {
if (!kioskModeEnabled()) return
stPolicy().setLockTaskAllowed(true)
try {
startLockTask()
Log.i("MainActivity", "Kiosk mode restored after restart")
} catch (e: Throwable) {
Log.w("MainActivity", "Kiosk restore failed: ${e.message}")
}
}
// #161: device-policy wrapper (degrades safely off-tier — every Tier-2 call no-ops when not owner).
private fun stPolicy() = com.remotedisplay.player.admin.STPolicy(this)
// #160 Track-A: no-device-owner system control (media volume, brightness, screen-off timeout).
@ -1194,7 +1432,17 @@ class MainActivity : AppCompatActivity() {
AlertDialog.Builder(this)
.setTitle(getString(R.string.settings_permissions))
.setMessage(lines)
.setPositiveButton(getString(R.string.settings_perm_open)) { _, _ ->
// Primary action opens OUR permissions screen — the one with a row per permission and
// a Manage button that stays visible once granted, so an installer can review or revoke
// what was given. Android's App Info page is still offered as the secondary, because a
// few things (notification access, some OEM toggles) are only reachable there.
.setPositiveButton(getString(R.string.settings_perm_manage)) { _, _ ->
startActivity(Intent(this, SetupActivity::class.java).apply {
// Review mode: leaving must return to playback, NOT restart pairing.
putExtra(SetupActivity.EXTRA_MANAGE_ONLY, true)
})
}
.setNeutralButton(getString(R.string.settings_perm_open)) { _, _ ->
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:$packageName")
}
@ -1264,8 +1512,57 @@ class MainActivity : AppCompatActivity() {
)
}
/**
* Nothing in the Android lifecycle pauses a WebView, so a YouTube embed kept playing with the
* app in the background and the panel kept making noise with the app "closed". Reported on
* #234. onStop (not onPause) is the right hook: onPause also fires for a transient dialog or a
* permission prompt, and pausing playback for those would be a visible stutter on a wall.
*/
override fun onStop() {
super.onStop()
if (::mediaPlayer.isInitialized) mediaPlayer.onAppBackgrounded()
}
override fun onStart() {
super.onStart()
if (::mediaPlayer.isInitialized) mediaPlayer.onAppForegrounded()
// Clear the boot "Starting display…" prompt EVERY time the player becomes visible, not
// just in onCreate.
//
// Relauncher launches the activity directly when the overlay permission is granted — the
// normal kiosk setup — and THEN posts the notification, deliberately, so a device that
// could not auto-launch still has a tappable way back. On a device where the launch DID
// work, that ordering means the prompt is posted after onCreate already cancelled it, and
// nothing clears it again: a permanent "Starting display…" banner over content that is
// already playing. Reported from the field with a photo of exactly that.
//
// If the player is on screen, the prompt is stale by definition — so clearing it here is
// correct regardless of who posted it or when.
(getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager)?.cancel(999)
// Re-enter kiosk if that is how the operator left it. Done in onStart rather than onCreate
// because lock-task can be dropped by the system on some transitions, and a panel that
// quietly stopped being locked is the failure people notice only when someone walks out of
// the app.
restoreKioskMode()
}
override fun onDestroy() {
remoteStreaming = false
// Everything below this line exists for the same reason the wall/group shutdown does, and
// was missing: these Handlers are on the MAIN LOOPER, which outlives the Activity.
//
// PlaylistController kept advancing after the Activity was destroyed. Each tick wrote the
// resume index and emitted play_start/play_end through the still-live WebSocketService, so
// after a relaunch (the "launch" command, Relauncher after OTA/boot, a re-pair, or a config
// change outside the ones we handle) TWO controllers were reporting playback for one screen
// — inflating Total Plays and Hours in Reports, and racing over the resume position that
// #234 relies on. Widget items also re-entered showWidget on a WebView nobody owned.
if (::playlistController.isInitialized) playlistController.stop()
if (::updateChecker.isInitialized) updateChecker.shutdown()
// The 30s failure-check loop and anything else this Activity posted.
handler.removeCallbacksAndMessages(null)
// Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper
// (outlives this Activity), so a surviving tick would keep broadcasting sync frames against
// the released player forever — the zombie-leader / split-brain / garbage-position leak.
@ -1288,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

@ -288,6 +288,13 @@ class ProvisioningActivity : AppCompatActivity() {
wsService?.onPaired = { deviceId, name ->
runOnUiThread {
// ONE-SHOT. This callback's only job is the hand-off to MainActivity, but it was
// being left installed on a service that OUTLIVES this Activity — and the server
// sends device:paired on EVERY register, not just the first. So each register
// relaunched MainActivity with CLEAR_TASK, which re-registered, which paired again:
// a self-sustaining loop, ~1.3 relaunches/second measured on Android 12. On 12+ every
// launch draws a splash screen, which is the "white screen flashing" users report.
clearServiceCallbacks()
cancelStuckTimer()
stopRepairTicker()
statusText.text = "Paired as: $name"
@ -298,6 +305,19 @@ class ProvisioningActivity : AppCompatActivity() {
finish()
}
}
}
/**
* Drop the callbacks this Activity installed on the long-lived service. MainActivity never
* assigns onRegistered/onUnpaired/onPaired, so nothing else would ever overwrite them they
* would keep firing into a destroyed Activity for the life of the process (and keep it alive).
*/
private fun clearServiceCallbacks() {
try {
wsService?.onPaired = null
wsService?.onUnpaired = null
wsService?.onRegistered = null
} catch (_: Throwable) { }
// Re-pair path: the socket is usually already up (service kept running). Make sure it's
// connecting, then render any pairing code the service already issued (race-free). If we're
@ -315,6 +335,10 @@ class ProvisioningActivity : AppCompatActivity() {
override fun onDestroy() {
cancelStuckTimer()
stopRepairTicker()
// Before unbinding: the service keeps running, so a callback left pointing here would both
// leak this Activity and keep firing. Matters when pairing never completes and the user
// backs out, where the one-shot clear above never runs.
clearServiceCallbacks()
if (bound) {
unbindService(connection)
bound = false

View file

@ -40,13 +40,29 @@ class SetupActivity : AppCompatActivity() {
private lateinit var enableWriteSettingsBtn: Button
private lateinit var continueBtn: Button
/**
* Opened from the in-service Settings menu to REVIEW permissions, not as first-run setup.
*
* The difference matters: proceedToNext() always goes to ProvisioningActivity, so without this
* a paired, playing screen would be sent to the pairing page by the button it was told to press.
* In manage mode the screen simply returns to the player.
*/
private val manageOnly: Boolean get() = intent?.getBooleanExtra(EXTRA_MANAGE_ONLY, false) == true
companion object {
const val EXTRA_MANAGE_ONLY = "EXTRA_MANAGE_ONLY"
}
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Skip setup if already completed
// Skip setup if already completed — but NOT when we were opened deliberately to review
// permissions from the in-service Settings menu. That is the whole point of manage mode:
// every device that can reach it has setup_complete set, so without this exemption the
// screen closes before it draws and the menu entry appears to do nothing.
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
if (prefs.getBoolean("setup_complete", false)) {
if (!manageOnly && prefs.getBoolean("setup_complete", false)) {
proceedToNext()
return
}
@ -56,7 +72,7 @@ class SetupActivity : AppCompatActivity() {
// moot — so skip the entire manual first-run wizard. Accessibility stays optional (it can't
// be auto-enabled). Guarded on ownership, so a NORMAL install still gets the full wizard.
val ownerPolicy = com.remotedisplay.player.admin.STPolicy(this)
if (ownerPolicy.isDeviceOwner()) {
if (!manageOnly && ownerPolicy.isDeviceOwner()) {
ownerPolicy.applyOnboardingPolicy()
prefs.edit().putBoolean("setup_complete", true).apply()
// Remote control needs the accessibility service, and it's the one thing no policy can
@ -98,9 +114,22 @@ class SetupActivity : AppCompatActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
findViewById<View>(R.id.notificationRow).visibility = View.VISIBLE
findViewById<Button>(R.id.enableNotificationBtn).setOnClickListener {
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100
)
val granted = ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
if (granted) {
// requestPermissions() does nothing once the answer is already given, so it
// cannot be the way back. App notification settings can toggle it either way.
try {
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
})
} catch (_: Exception) { openAppSettings() }
} else {
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100
)
}
}
}
@ -147,6 +176,18 @@ class SetupActivity : AppCompatActivity() {
// Default launcher / HOME: a kiosk MUST be the default launcher, else Android returns to the
// stock launcher and tears down + recreates the player on a loop (it never renders). Request
// the HOME role (clean system dialog on API 29+); fall back to the Home-app picker in Settings.
// OPTIONAL: location, solely so the device page can show the Wi-Fi network name. Requested
// only when someone taps this row — never at startup, and nothing else in the player depends
// on it. Once granted (or permanently denied) requestPermissions() stops prompting, so an
// already-answered row sends you to app settings where it can be changed either way.
findViewById<Button>(R.id.enableLocationBtn).setOnClickListener {
if (hasLocationPermission()) openAppSettings()
else ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
101
)
}
findViewById<Button>(R.id.enableLauncherBtn).setOnClickListener { promptSetDefaultLauncher() }
// Launch-on-boot needs USE_FULL_SCREEN_INTENT, which Android 14+ auto-revokes
@ -173,17 +214,35 @@ class SetupActivity : AppCompatActivity() {
// Battery-optimization exemption keeps the boot receiver from being deferred
// and the app from being killed in standby (esp. on OEM / TV boxes).
enableBatteryBtn.setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
})
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
val exempt = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.isIgnoringBatteryOptimizations(packageName)
if (exempt) {
// ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS only ASKS to add an exemption — it
// offers no way to remove one, so it is a dead end for someone already exempt.
// The system list is where an exemption can actually be turned back off.
try { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) }
catch (_: Exception) { openAppSettings() }
} else {
try {
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
})
} catch (e: Exception) {
try { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) }
catch (_: Exception) { openAppSettings() }
}
}
}
if (manageOnly) {
// "Continue anyway" and the skip hint are first-run language; here the only action is
// to go back to what was already playing.
continueBtn.text = getString(R.string.settings_perm_done)
findViewById<TextView>(R.id.skipText).visibility = View.GONE
}
continueBtn.setOnClickListener {
prefs.edit().putBoolean("setup_complete", true).apply()
if (!manageOnly) prefs.edit().putBoolean("setup_complete", true).apply()
proceedToNext()
}
@ -200,6 +259,30 @@ class SetupActivity : AppCompatActivity() {
updateStatuses()
}
/**
* Bind a permission row's button. Granted rows used to set the button GONE, which left the
* choice one-way: every permission on this screen is granted in system Settings and the app
* cannot revoke any of them itself, so hiding the only route to that screen meant there was no
* way back. Reported on #234 "if I make the app as Home launcher but later on want to remove
* it then how can I do it?".
*
* The button now stays put and relabels. Same tap target, same destination; the label is honest
* that Settings is where the change happens rather than promising we can revoke it ourselves.
*/
private fun bindPermissionButton(btn: Button, granted: Boolean, enableLabel: String) {
btn.visibility = View.VISIBLE
btn.text = if (granted) "Manage" else enableLabel
}
/** Last-resort destination: this app's own settings page, where everything can be reached. */
private fun openAppSettings() {
try {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
})
} catch (_: Exception) { try { startActivity(Intent(Settings.ACTION_SETTINGS)) } catch (_: Exception) {} }
}
private fun updateStatuses() {
// Accessibility
val accessibilityEnabled = isAccessibilityEnabled()
@ -207,7 +290,7 @@ class SetupActivity : AppCompatActivity() {
accessibilityStatus.setTextColor(
if (accessibilityEnabled) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
enableAccessibilityBtn.visibility = if (accessibilityEnabled) View.GONE else View.VISIBLE
bindPermissionButton(enableAccessibilityBtn, accessibilityEnabled, "Enable")
// Install unknown apps
val canInstall = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -217,7 +300,7 @@ class SetupActivity : AppCompatActivity() {
installStatus.setTextColor(
if (canInstall) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
enableInstallBtn.visibility = if (canInstall) View.GONE else View.VISIBLE
bindPermissionButton(enableInstallBtn, canInstall, "Enable")
// Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -228,8 +311,7 @@ class SetupActivity : AppCompatActivity() {
notificationStatus.setTextColor(
if (hasNotif) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
findViewById<Button>(R.id.enableNotificationBtn).visibility =
if (hasNotif) View.GONE else View.VISIBLE
bindPermissionButton(findViewById(R.id.enableNotificationBtn), hasNotif, "Enable")
}
// Launch on boot (full-screen intent — only restrictable on Android 14+)
@ -237,7 +319,7 @@ class SetupActivity : AppCompatActivity() {
val canFsi = getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
fullscreenStatus.text = if (canFsi) "ON" else "OFF"
fullscreenStatus.setTextColor(if (canFsi) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableFullscreenBtn.visibility = if (canFsi) View.GONE else View.VISIBLE
bindPermissionButton(enableFullscreenBtn, canFsi, "Enable")
}
// Battery optimization exemption
@ -245,33 +327,66 @@ class SetupActivity : AppCompatActivity() {
.isIgnoringBatteryOptimizations(packageName)
batteryStatus.text = if (ignoringBattery) "ON" else "OFF"
batteryStatus.setTextColor(if (ignoringBattery) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableBatteryBtn.visibility = if (ignoringBattery) View.GONE else View.VISIBLE
bindPermissionButton(enableBatteryBtn, ignoringBattery, "Enable")
// Display over other apps
val canOverlay = Settings.canDrawOverlays(this)
overlayStatus.text = if (canOverlay) "ON" else "OFF"
overlayStatus.setTextColor(if (canOverlay) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableOverlayBtn.visibility = if (canOverlay) View.GONE else View.VISIBLE
bindPermissionButton(enableOverlayBtn, canOverlay, "Enable")
// #160 WRITE_SETTINGS (system brightness / screen-off timeout)
val canWrite = Settings.System.canWrite(this)
writeSettingsStatus.text = if (canWrite) "ON" else "OFF"
writeSettingsStatus.setTextColor(if (canWrite) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableWriteSettingsBtn.visibility = if (canWrite) View.GONE else View.VISIBLE
bindPermissionButton(enableWriteSettingsBtn, canWrite, "Enable")
// Optional Wi-Fi-name permission
val hasLoc = hasLocationPermission()
val locationStatus = findViewById<TextView>(R.id.locationStatus)
locationStatus.text = if (hasLoc) "ON" else "OFF"
locationStatus.setTextColor(if (hasLoc) 0xFF22C55E.toInt() else 0xFF64748B.toInt())
bindPermissionButton(findViewById(R.id.enableLocationBtn), hasLoc, "Enable")
// Default launcher (HOME): kiosk foreground stability requires being the default launcher.
val isDefaultHome = isDefaultLauncher()
val launcherStatus = findViewById<TextView>(R.id.launcherStatus)
launcherStatus.text = if (isDefaultHome) "ON" else "OFF"
launcherStatus.setTextColor(if (isDefaultHome) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
findViewById<Button>(R.id.enableLauncherBtn).visibility = if (isDefaultHome) View.GONE else View.VISIBLE
bindPermissionButton(findViewById(R.id.enableLauncherBtn), isDefaultHome, "Set")
// Update continue button text
val allGood = accessibilityEnabled && canInstall
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"
// updateStatuses() runs after onCreate's setup and re-labels this button every time, so the
// manage-mode label has to be honoured HERE too — setting it once earlier was silently
// overwritten. In review mode there is nothing to continue TO; the only action is going back.
continueBtn.text = when {
manageOnly -> getString(R.string.settings_perm_done)
allGood -> "Continue to Setup"
else -> "Continue Anyway"
}
}
/** Either location permission is enough for the SSID; coarse suffices below Android 10. */
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun isDefaultLauncher(): Boolean {
// Ask the SAME authority the action uses. This used to read resolveActivity(MATCH_DEFAULT_ONLY),
// which can name us when we are merely a HOME candidate rather than the chosen home app — so
// the row could say ON while the system still had the OEM launcher as home, and the button
// then opened the "become home" request dialog instead of the picker. Reported on #234:
// "in the apk I have granted the permission ... BUT in the settings of the tablet it still
// shows the tablet native launcher as home."
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
val rm = getSystemService(android.app.role.RoleManager::class.java)
if (rm != null && rm.isRoleAvailable(android.app.role.RoleManager.ROLE_HOME)) {
return rm.isRoleHeld(android.app.role.RoleManager.ROLE_HOME)
}
} catch (_: Exception) { /* fall through to the pre-Q check */ }
}
val ri = packageManager.resolveActivity(
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
PackageManager.MATCH_DEFAULT_ONLY
@ -313,6 +428,8 @@ class SetupActivity : AppCompatActivity() {
}
private fun proceedToNext() {
// Reviewing permissions on a live screen must never restart pairing — just go back.
if (manageOnly) { finish(); return }
startActivity(Intent(this, ProvisioningActivity::class.java))
finish()
}

View file

@ -12,10 +12,29 @@ import java.util.concurrent.TimeUnit
* Root-2 caching fixes vs the "stuck downloading / frozen" bug:
* - a hard OVERALL [callTimeout] so a slow-drip/stalled download on a HEALTHY socket can't hang
* forever (the old client only had a per-read timeout, which a trickle never trips),
* - download to a `.part` temp + integrity-check (Content-Length) via [CacheValidation] + atomic
* rename, so a truncated/interrupted body is NEVER promoted to the cache and played as if whole,
* - download to a `.part` temp + integrity-check via [CacheValidation] + atomic rename, so a
* truncated/interrupted body is NEVER promoted to the cache and played as if whole,
* - exact-prefix cache lookup that also excludes in-flight `.part` files.
*
* RESUME. Those fixes made a bad download safe; they did not make it possible. Every attempt began
* at byte 0 and the `.part` was deleted on failure, so on a link that cannot carry a whole asset in
* one unbroken call a one-bar 5G site, the case this was reported from the file NEVER lands.
* The player then has nothing cached, and a screen with nothing cached shows the waiting state:
* reported as "the screens go black instead of playing from cache", when the real failure was that
* the cache could never be filled in the first place. Five minutes of transfer, discarded; back
* off; five more minutes, discarded; forever.
*
* So an interrupted download now KEEPS its `.part` and the next attempt asks for the rest with a
* Range header. Progress accumulates across attempts and across reboots instead of being thrown
* away, which is the whole difference between "eventually plays" and "never plays".
*
* Two ways a resume could corrupt the cache, both closed:
* - the asset changed under us `If-Range` with the stored validator makes the server answer 200
* with the whole body instead of a tail, and we restart from zero.
* - the `.part` is longer than the asset the server answers 416 and we discard it.
* The completeness check is unchanged in spirit but now counts TOTAL bytes on disk against the
* total the server declared in Content-Range, not bytes received this attempt.
*
* The primary constructor takes the cache dir + client directly so the real download logic is
* unit-testable (see ContentDownloadTest) against a local server without an Android Context; the
* [Context] convenience constructor is what the app uses.
@ -29,12 +48,27 @@ class ContentCache internal constructor(
defaultClient()
)
/**
* What one download attempt achieved. The distinction that matters is [Partial] vs [Failed]:
* an attempt that moved bytes onto disk is PROGRESS, and backing that off exponentially the way
* a hard failure is backed off is what turns a slow site into a dead one.
*/
sealed class Result {
data class Done(val file: File) : Result()
/** Bytes are on disk and the next attempt resumes from there. */
data class Partial(val bytesOnDisk: Long, val totalBytes: Long, val progressed: Boolean) : Result()
/** Nothing usable happened: refused, unreachable, or a stale partial we had to discard. */
object Failed : Result()
}
fun getCachedFile(contentId: String): File? {
// Match "<id>.<ext>" exactly: the trailing dot stops an id that PREFIXES another id from
// cross-matching, and `.part` temps (partial/in-flight downloads) are never returned.
val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.endsWith(PART_SUFFIX) }
// cross-matching. `contains` rather than `endsWith` for the temp check because the resume
// validator sidecar is "<id>.<ext>.part.tag" — it does not END with ".part", and returning
// THAT as the cached asset would hand the player a short ETag file to play.
val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.contains(PART_SUFFIX) && !name.endsWith(REV_SUFFIX) }
val hit = files?.firstOrNull()?.takeIf { it.exists() && it.length() > 0 }
com.remotedisplay.player.util.DebugLog.v("ContentCache", "getCachedFile($contentId): dir=${cacheDir.absolutePath} listFiles=${files?.size} -> ${hit?.name ?: "MISS"}")
com.remotedisplay.player.util.DebugLog.v("ContentCache", "getCachedFile($contentId): dir=${cacheDir.absolutePath} listFiles=${files?.size ?: -1} hit=${hit?.name}")
return hit
}
@ -42,61 +76,172 @@ class ContentCache internal constructor(
return getCachedFile(contentId) != null
}
fun downloadContent(serverUrl: String, contentId: String, filename: String): File? {
/**
* Cached AND holding the revision the playlist is asking for.
*
* The dashboard can replace an asset's bytes under a stable content id, which is the one way a
* cached copy can be permanently wrong: the id does not change, the filename does not change,
* and nothing about a plain existence check can tell. A panel would keep playing last month's
* video until somebody deleted and re-added the item. Comparing the revision is what makes
* "cached for offline" compatible with "and it still updates".
*
* A revision of 0 means the server never sent one (an older build): fall back to existence, so
* an upgrade does not re-download the entire playlist over the link least able to afford it.
*/
fun isContentCached(contentId: String, rev: Long): Boolean {
val file = getCachedFile(contentId) ?: return false
if (rev <= 0L) return true
return readRev(file) == rev
}
private fun revFile(file: File) = File(file.absolutePath + REV_SUFFIX)
private fun readRev(file: File): Long =
try { revFile(file).takeIf { it.exists() }?.readText()?.trim()?.toLongOrNull() ?: 0L }
catch (e: Exception) { 0L }
/**
* Fetch (or continue fetching) [contentId]. Safe to call repeatedly: each call transfers what
* the link allows and leaves the rest for the next one.
*/
fun fetch(serverUrl: String, contentId: String, filename: String, rev: Long = 0L): Result {
val ext = filename.substringAfterLast('.', "mp4")
val finalFile = File(cacheDir, "${contentId}.${ext}")
val partFile = File(cacheDir, "${contentId}.${ext}${PART_SUFFIX}")
val finalFile = File(cacheDir, "$contentId.$ext")
val partFile = File(cacheDir, "$contentId.$ext$PART_SUFFIX")
val tagFile = File(cacheDir, "$contentId.$ext$PART_SUFFIX$TAG_SUFFIX")
// Only resume when we also hold the validator that was current when those bytes were
// fetched. Without it there is no way to know the asset is still the same one, and a silent
// splice of two files is worse than re-downloading.
val validator = readValidator(tagFile)
val resumeFrom = if (validator != null && partFile.exists()) partFile.length() else 0L
if (resumeFrom == 0L) { partFile.delete(); tagFile.delete() }
try {
val url = "${serverUrl}/api/content/${contentId}/file"
val request = Request.Builder().url(url).build()
// The revision rides in the URL as well as in the sidecar: an intermediary caching
// /api/content/<id>/file would otherwise happily serve the superseded bytes to every
// panel behind it, and no amount of client-side bookkeeping could tell.
val url = "$serverUrl/api/content/$contentId/file" + (if (rev > 0L) "?rev=$rev" else "")
val builder = Request.Builder().url(url)
if (resumeFrom > 0) {
builder.header("Range", "bytes=$resumeFrom-")
builder.header("If-Range", validator!!)
}
// .use closes the Response (and its body) on every path — also fixes the prior
// error-path body/connection leak.
client.newCall(request).execute().use { response ->
client.newCall(builder.build()).execute().use { response ->
// Our partial is at or past the end of the asset: it belongs to something else, or
// to a truncated earlier life of this file. Discard and start clean next time.
if (response.code == 416) {
Log.w("ContentCache", "Server refused resume at $resumeFrom for $filename (416) — discarding stale partial")
partFile.delete(); tagFile.delete()
return Result.Failed
}
if (!response.isSuccessful) {
Log.e("ContentCache", "Download failed: ${response.code}")
return null
// The partial is kept: a 5xx or a captive-portal interception says nothing about
// the bytes we already hold.
return if (resumeFrom > 0) Result.Partial(resumeFrom, -1L, false) else Result.Failed
}
// We issue a plain (no-Range) GET, so a 206 Partial Content means a proxy/CDN
// returned a PARTIAL body whose Content-Length matches that partial — which would
// pass the byte-count integrity check and promote a truncated file. Require a full 200.
val body = response.body ?: return Result.Failed
val appendAt: Long
val total: Long
if (response.code == 206) {
Log.e("ContentCache", "Refusing 206 Partial Content for $filename — not a complete file")
return null
}
partFile.delete() // clear any earlier partial before writing
val body = response.body ?: return null
val expected = body.contentLength() // -1 when unknown (chunked)
var written = 0L
body.byteStream().use { input ->
FileOutputStream(partFile).use { output -> written = input.copyTo(output) }
}
// Root-2: a truncated body must NOT be promoted to the cache and played as whole.
if (!CacheValidation.isComplete(written, expected)) {
Log.e("ContentCache", "Incomplete download ($written/$expected bytes) for $filename — discarding partial")
// Content-Length on a 206 is the length of the CHUNK, so the only trustworthy
// source for the full size is the total in Content-Range.
val range = parseContentRange(response.header("Content-Range"))
if (range == null || range.first != resumeFrom || range.second <= 0L) {
// A 206 we cannot verify, or one starting somewhere we did not ask for.
// Appending it blind would corrupt the file at exactly the byte count that
// makes it look complete.
Log.e("ContentCache", "Unusable 206 for $filename (Content-Range=${response.header("Content-Range")}, wanted $resumeFrom) — restarting")
partFile.delete(); tagFile.delete()
return Result.Failed
}
appendAt = resumeFrom
total = range.second
if (!tagFile.exists()) writeValidator(tagFile, response.header("ETag") ?: response.header("Last-Modified"))
} else {
// 200. Either we sent no Range, or If-Range told the server the asset changed
// and it sent the whole thing instead of a tail. Both mean: start from zero.
if (resumeFrom > 0) Log.i("ContentCache", "Asset changed under a resume for $filename — restarting from 0")
appendAt = 0L
partFile.delete()
return null
total = body.contentLength() // -1 when unknown (chunked)
writeValidator(tagFile, response.header("ETag") ?: response.header("Last-Modified"))
}
var onDisk = appendAt
try {
body.byteStream().use { input ->
FileOutputStream(partFile, appendAt > 0).use { output ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
output.write(buf, 0, n)
onDisk += n
}
output.flush()
// Durable, so a power cut on a signage panel costs the last buffer
// rather than the whole partial. These are big files on bad links; the
// sync is cheap next to re-fetching 200MB.
try { output.fd.sync() } catch (_: Exception) {}
}
}
} catch (e: Exception) {
// The break we now RECOVER from instead of restarting after. Includes the read
// timeout (a stalled stream) and the call timeout (a slow drip that ran out of
// its attempt budget) — on a bad link these are the normal case, not the
// exception, and everything written so far stays.
Log.i("ContentCache", "Download interrupted for $filename at ${partFile.length()} bytes (${e.message})")
return partialOrDiscard(partFile, tagFile, partFile.length(), total, resumeFrom)
}
// Total bytes on disk against the declared total — NOT bytes received this attempt,
// which on a resume is only the tail.
if (!CacheValidation.isComplete(onDisk, total)) {
Log.i("ContentCache", "Incomplete after this attempt ($onDisk/$total) for $filename")
return partialOrDiscard(partFile, tagFile, onDisk, total, resumeFrom)
}
finalFile.delete()
if (!partFile.renameTo(finalFile)) {
Log.e("ContentCache", "Rename failed for $filename")
partFile.delete()
return null
partFile.delete(); tagFile.delete()
return Result.Failed
}
Log.i("ContentCache", "Downloaded: $filename -> ${finalFile.absolutePath} ($written bytes)")
return finalFile
tagFile.delete()
// Record WHICH revision these bytes are, so a later replace is detectable. Written
// after the rename: a revision marker next to a file that is not there yet would
// claim a cached asset that does not exist.
try {
if (rev > 0L) revFile(finalFile).writeText(rev.toString()) else revFile(finalFile).delete()
} catch (e: Exception) { /* the file is still usable; worst case it re-downloads */ }
Log.i("ContentCache", "Downloaded: $filename -> ${finalFile.absolutePath} ($onDisk bytes)")
return Result.Done(finalFile)
}
} catch (e: Exception) {
// Includes callTimeout / readTimeout (a stalled download on a healthy socket) and any
// mid-stream break — never leave a partial at the real path.
// Connect-time failures (no route, DNS, TLS) — nothing was transferred, so whatever is
// already on disk is still valid to resume from. This also catches a throw from closing
// the response after a partial body, which is why `progressed` is measured against the
// bytes on disk rather than assumed false: an attempt that advanced must not be
// reported as a stall just because the connection objected on the way out.
Log.e("ContentCache", "Download error: ${e.message}")
partFile.delete()
return null
val kept = partFile.length()
return if (kept > 0) partialOrDiscard(partFile, tagFile, kept, -1L, resumeFrom) else Result.Failed
}
}
/** Complete-or-nothing wrapper: returns the cached file only when the asset is whole. */
fun downloadContent(serverUrl: String, contentId: String, filename: String, rev: Long = 0L): File? =
(fetch(serverUrl, contentId, filename, rev) as? Result.Done)?.file
fun deleteContent(contentId: String) {
// Exact-prefix (with the dot) so we don't delete a different id's file — and this also
// sweeps the "<id>.<ext>.part" temp.
// sweeps the "<id>.<ext>.part" temp and its ".part.tag" validator.
cacheDir.listFiles { _, name -> name.startsWith("$contentId.") }?.forEach { it.delete() }
Log.i("ContentCache", "Deleted cached content: $contentId")
}
@ -109,14 +254,56 @@ class ContentCache internal constructor(
return cacheDir.listFiles()?.sumOf { it.length() } ?: 0L
}
/**
* Report an unfinished attempt keeping the bytes only if the NEXT attempt can build on them.
*
* Without a validator there is no safe resume, so the partial is dead weight: the next attempt
* would restart from zero, re-fetch the same prefix, and land in exactly the same place. Worse,
* counting that as progress would make the coordinator chain attempts against a link that is
* getting nowhere. Discard it and report no progress, so it backs off like the failure it is.
*/
private fun partialOrDiscard(partFile: File, tagFile: File, onDisk: Long, total: Long, resumeFrom: Long): Result {
if (!tagFile.exists()) {
partFile.delete()
return Result.Partial(0L, total, false)
}
return Result.Partial(onDisk, total, onDisk > resumeFrom)
}
private fun readValidator(tagFile: File): String? =
try { if (tagFile.exists()) tagFile.readText().trim().ifEmpty { null } else null } catch (_: Exception) { null }
private fun writeValidator(tagFile: File, value: String?) {
// No validator (a server that sends neither ETag nor Last-Modified) means no safe resume:
// leave the sidecar absent and the next attempt starts over rather than splicing blind.
try { if (value.isNullOrBlank()) tagFile.delete() else tagFile.writeText(value) } catch (_: Exception) {}
}
companion object {
private const val PART_SUFFIX = ".part"
private const val TAG_SUFFIX = ".tag"
private const val REV_SUFFIX = ".rev"
/**
* "bytes <start>-<end>/<total>" -> (start, total). Null for anything else, including the
* "*" total a server may send, which gives us nothing to validate completeness against.
*/
internal fun parseContentRange(header: String?): Pair<Long, Long>? {
val m = Regex("""^\s*bytes\s+(\d+)-(\d+)/(\d+)\s*$""").find(header ?: return null) ?: return null
val start = m.groupValues[1].toLongOrNull() ?: return null
val total = m.groupValues[3].toLongOrNull() ?: return null
return start to total
}
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) // Root-2: a stalled stream (no bytes 30s) aborts (was 5min)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(5, TimeUnit.MINUTES) // Root-2: hard OVERALL cap so a slow-drip can't hang forever
// Root-2 gave this a hard OVERALL cap so a slow drip could not hang forever. With resume
// it caps ONE ATTEMPT rather than the whole asset: a link that only manages 20MB per
// call now keeps those 20MB and continues, where before the cap was the reason a large
// file could never finish.
.callTimeout(5, TimeUnit.MINUTES)
.build()
}
}

View file

@ -49,32 +49,67 @@ class DownloadCoordinator(
* sweep the 60s refresh and every post-reconnect re-register included. Idempotent and
* non-blocking: it enqueues at most ONE download per contentId and returns immediately.
*/
fun ensure(contentId: String, filename: String) {
fun ensure(contentId: String, filename: String, rev: Long = 0L) {
if (contentId.isEmpty()) return
if (cache.isContentCached(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): SEED-A cached -> ack ready"); onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A)
if (cache.isContentCached(contentId, rev)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): SEED-A cached -> ack ready"); onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A)
// Socket down => the WATCHDOG owns recovery; don't hammer downloads over a dead connection.
if (!socketAlive()) { DebugLog.v("DownloadCoordinator", "ensure($contentId): socket not alive -> skip"); return }
if (now() < (nextAttemptAt[contentId] ?: 0L)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): in backoff until ${nextAttemptAt[contentId]} -> skip"); return } // in failure backoff — don't storm
if (!inFlight.add(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): already inFlight -> skip"); return } // single-flight: already downloading
DebugLog.v("DownloadCoordinator", "ensure($contentId): dispatching download '$filename'")
try {
executor.execute { runDownload(contentId, filename) }
executor.execute { runDownload(contentId, filename, rev) }
} catch (e: Throwable) {
inFlight.remove(contentId) // executor rejected (shut down) — don't leak the guard
}
}
private fun runDownload(contentId: String, filename: String) {
/**
* Run attempts back-to-back for as long as each one is putting bytes on disk.
*
* A site whose link only carries part of an asset per call needs many attempts to finish one
* file. Handing each attempt back to the 60s playlist sweep would stretch a 200MB video over
* hours of mostly-idle waiting, and running to the exponential backoff would stretch it to
* never which is the failure this whole change is about. Progress is the signal: keep going
* while bytes are landing, stop the moment one attempt achieves nothing.
*
* inFlight is held across the whole chain, so this is still exactly one writer per `.part` and
* a concurrent sweep still finds the item busy rather than starting a duplicate.
*/
private fun runDownload(contentId: String, filename: String, rev: Long) {
try {
val file = cache.downloadContent(serverUrl(), contentId, filename)
if (file != null) {
attempts.remove(contentId); nextAttemptAt.remove(contentId)
// Ack only reaches a live socket; if it dropped, the reconnect's re-register clears
// the ack set and the next sweep re-acks the now-cached file.
if (socketAlive()) onAck(contentId, "ready")
} else {
onFailure(contentId) // includes a reconnect-truncated .part (ContentCache returned null)
var link = 0
while (link < MAX_RESUME_CHAIN) {
link++
val result = cache.fetch(serverUrl(), contentId, filename, rev)
when (result) {
is ContentCache.Result.Done -> {
attempts.remove(contentId); nextAttemptAt.remove(contentId)
// Ack only reaches a live socket; if it dropped, the reconnect's re-register
// clears the ack set and the next sweep re-acks the now-cached file.
if (socketAlive()) onAck(contentId, "ready")
return
}
is ContentCache.Result.Partial -> {
if (!result.progressed) {
// Bytes are held but this attempt added none: the link is down rather
// than slow, so back off properly instead of spinning on it.
onFailure(contentId)
return
}
DebugLog.v("DownloadCoordinator", "resume $contentId: ${result.bytesOnDisk}/${result.totalBytes} after attempt $link")
// Deliberately NOT acked as failed. A download that is advancing is not a
// failure, and telling the CMS otherwise is what put items in the dashboard
// showing "failed" while they were in fact still arriving.
}
is ContentCache.Result.Failed -> { onFailure(contentId); return }
}
}
// Still going when the chain ran out. Not a failure — hand it back to the next sweep
// with a short, FIXED delay rather than the exponential one, so a large asset over a
// slow link keeps advancing instead of decaying into the 5-minute cap.
nextAttemptAt[contentId] = now() + RESUME_HANDBACK_MS
DebugLog.v("DownloadCoordinator", "resume chain exhausted for $contentId — continuing on the next sweep")
} catch (e: Throwable) {
Log.w("DownloadCoordinator", "download $contentId failed: ${e.message}")
onFailure(contentId)
@ -125,5 +160,13 @@ class DownloadCoordinator(
const val MAX_CONCURRENT = 3
const val BACKOFF_BASE_MS = 15_000L
const val BACKOFF_MAX_MS = 5 * 60_000L
/**
* Consecutive resuming attempts before the item goes back on the sweep. Bounded so one
* enormous asset on a slow link cannot hold an executor slot indefinitely and starve the
* other two items in a playlist with a 5-minute attempt cap this is still up to an hour
* of continuous transfer per dispatch.
*/
const val MAX_RESUME_CHAIN = 12
const val RESUME_HANDBACK_MS = 5_000L
}
}

View file

@ -98,6 +98,17 @@ class ServerConfig(context: Context) {
get() = prefs.getString("cached_playlist", "") ?: ""
set(value) = prefs.edit().putString("cached_playlist", value).apply()
// #234: last playing index + when it started. Lives here, not in PlaylistController, precisely
// because the controller is rebuilt with every Activity — which is how a relaunch used to reset
// playback to the first item and starve everything after it.
var resumeIndex: Int
get() = prefs.getInt("resume_index", -1)
set(value) = prefs.edit().putInt("resume_index", value).apply()
var resumeAt: Long
get() = prefs.getLong("resume_at", 0L)
set(value) = prefs.edit().putLong("resume_at", value).apply()
fun clearPlaylistCache() {
prefs.edit().remove("cached_playlist").apply()
}

View file

@ -152,6 +152,8 @@ class MediaPlayerManager(
// Plain image mount (visibility flip + set bitmap). Shared by the transition-done swap and the
// no-transition hard cut.
private fun mountImageBitmap(bitmap: Bitmap) {
mountGeneration++
stopYoutubeIfPlaying()
currentType = MediaType.IMAGE
currentWidgetUrl = null // surface reused - a later widget show must reload
playerView.visibility = android.view.View.GONE
@ -162,8 +164,27 @@ class MediaPlayerManager(
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
}
/**
* Stop a YouTube embed that is being switched away from.
*
* Hiding the WebView does NOT stop it visibility is not playback state, so the video kept
* running behind the next item and its audio carried on over the top. Reported after YouTube
* items started advancing at all (before that they never ended, so nothing ever switched away
* from one and this could not surface): "even when the picture is there the sound from the
* video continues playing".
*
* Blanking is what stop() already does, and it is safe here because playYoutube always reloads
* the embed from scratch anyway. Guarded on the OUTGOING type so it must be called before
* currentType is reassigned, and so it never blanks a widget that is being reused.
*/
private fun stopYoutubeIfPlaying() {
if (currentType != MediaType.YOUTUBE) return
youtubeWebView?.loadUrl("about:blank")
}
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
mountGeneration++
currentType = MediaType.YOUTUBE
currentWidgetUrl = null // surface reused - a later widget show must reload
youtubeMuted = muted || wallMute
@ -190,13 +211,42 @@ class MediaPlayerManager(
// would restart the video and flicker. Main thread only (WebView access).
private fun setYoutubeMuted(muted: Boolean) {
youtubeMuted = muted
val func = if (muted) "mute" else "unMute"
postYoutubeCommand(if (muted) "mute" else "unMute")
}
/** Send one IFrame-API command to the embed. Main thread only (WebView access). */
private fun postYoutubeCommand(func: String) {
val js = "(function(){try{var f=document.querySelector('iframe');" +
"if(f&&f.contentWindow){f.contentWindow.postMessage(" +
"JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()"
youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } }
}
/**
* The app is going to the background. Stop making noise.
*
* A WebView keeps running when its Activity stops nothing in the lifecycle pauses it so a
* YouTube embed carried on playing with the app closed and the audio kept coming out of the
* panel: "I closed the app and I can still hear the sound... I force stop the app and then open
* again." A signage player that is not on screen must be silent.
*
* Pause rather than blank, so returning to the foreground resumes in place instead of
* restarting the clip. pauseTimers() is process-wide, which is fine here (one WebView) and is
* what actually stops the embed's own scripted playback.
*/
fun onAppBackgrounded() {
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("pauseVideo")
youtubeWebView?.let { wv -> wv.post { try { wv.onPause(); wv.pauseTimers() } catch (_: Throwable) {} } }
exoPlayer?.pause()
}
/** Back in the foreground: undo onAppBackgrounded. */
fun onAppForegrounded() {
youtubeWebView?.let { wv -> wv.post { try { wv.resumeTimers(); wv.onResume() } catch (_: Throwable) {} } }
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("playVideo")
if (currentType == MediaType.VIDEO) exoPlayer?.play()
}
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
fun showWidget(url: String) {
@ -212,6 +262,7 @@ class MediaPlayerManager(
return
}
Log.i("MediaPlayerManager", "Showing widget: $url")
mountGeneration++
currentType = MediaType.WIDGET
currentWidgetUrl = url
@ -229,6 +280,8 @@ class MediaPlayerManager(
fun playVideoFromUrl(url: String, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Streaming video from URL: $url (muted=$muted)")
mountGeneration++
stopYoutubeIfPlaying()
currentType = MediaType.VIDEO
currentWidgetUrl = null // surface reused - a later widget show must reload
@ -244,13 +297,36 @@ class MediaPlayerManager(
}
}
/**
* Bumped by every request to put something on screen. An async decode captures it and drops its
* result if the value has moved on the same drop-if-replaced token PipOverlay.loadImageInto
* already carries.
*
* Without it a slow remote image (ImageLoader allows 10s connect + 30s read, against a slot
* that is usually 10s) finished long after the playlist had advanced and mounted itself over
* whatever was playing. If that was a video, the mount also called exoPlayer.stop(), which
* lands in STATE_IDLE and the advance listener only fires onVideoComplete on STATE_ENDED or a
* playback error, so no advance was ever scheduled and the playlist stopped for good. The 60s
* refresh could not rescue it either: the playlist signature was unchanged, so the update
* returned early.
*/
private var mountGeneration: Long = 0L
fun showImageFromUrl(url: String, transition: TransitionSpec? = null) {
Log.i("MediaPlayerManager", "Loading remote image: $url")
// Capture the outgoing frame NOW, on the main thread, before the decode thread swaps it out.
val from = if (transition != null) captureCurrentFrame() else null
val myGeneration = ++mountGeneration
Thread {
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
mainHandler.post {
// Something else has been asked for since this decode started — including the
// error branch, whose onImageError posts next() and would otherwise cut short
// whatever is now playing.
if (myGeneration != mountGeneration) {
Log.i("MediaPlayerManager", "Dropping stale image decode: $url")
return@post
}
if (bitmap == null) {
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
onImageError?.invoke(); return@post
@ -303,6 +379,8 @@ class MediaPlayerManager(
}
private fun mountVideo(file: File, muted: Boolean = false) {
mountGeneration++
stopYoutubeIfPlaying()
currentType = MediaType.VIDEO
currentWidgetUrl = null // surface reused - a later widget show must reload

View file

@ -19,6 +19,13 @@ data class PlaylistItem(
val remoteUrl: String? = null,
val muted: Boolean = false,
val widgetId: String? = null,
// Changes whenever the widget is edited. Carried into the render URL so an edited widget gets
// a URL the player has not seen, which is what defeats the deliberate same-URL WebView reuse.
val widgetRev: Long = 0L,
// Bumped by the server when an asset's BYTES change under a stable content id (the dashboard's
// "replace file"). The cache is keyed on it: without one, a replaced asset would keep playing
// the copy already on disk forever, because nothing about the id or the URL would differ.
val contentRev: Long = 0L,
val widgetType: String? = null,
val schedules: List<ScheduleEval.Block> = emptyList(),
// feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut).
@ -39,7 +46,11 @@ class PlaylistController(
private val onWaitingForContent: (() -> Unit)? = null,
// Proof-of-play: emitted on each item show ("play_start") and when it's left ("play_end"),
// so the caller can forward device:play-event to the server (populates play_logs / Reports).
private val onPlayLog: ((event: String, item: PlaylistItem, completed: Boolean) -> Unit)? = null
private val onPlayLog: ((event: String, item: PlaylistItem, completed: Boolean) -> Unit)? = null,
// #234: playback position, persisted OUTSIDE this object so it survives the controller being
// rebuilt with a new Activity. Null on both = today's behaviour (always start from the top).
private val loadResume: (() -> Pair<Int, Long>?)? = null,
private val saveResume: ((index: Int, atMs: Long) -> Unit)? = null
) {
private companion object {
const val CONTENT_RECHECK_MS = 3000L
@ -107,7 +118,7 @@ class PlaylistController(
val item = currentItem ?: return
val delay = FollowerExit.resumeDelayMs(
isRunning = isRunning,
isImageOrWidget = item.mimeType.startsWith("image/") || item.isWidget,
isImageOrWidget = endsOnTimer(item),
slotMs = slotMs(item),
elapsedMs = System.currentTimeMillis() - itemStartedAt
) ?: return
@ -165,6 +176,8 @@ class PlaylistController(
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
muted = obj.optInt("muted", 0) == 1,
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
widgetRev = obj.optLong("widget_rev", 0L),
contentRev = obj.optLong("content_rev", 0L),
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
schedules = parseSchedules(obj.optJSONArray("schedules")),
transition = Transitions.parse(obj.optJSONObject("transition"))
@ -185,7 +198,12 @@ class PlaylistController(
// so timing edits take effect without interrupting playback or resetting the index.
// transition included so a transition-only edit re-renders instead of being de-duped (a
// cached-playlist device otherwise silently ignores it — the web/Tizen fingerprint bug).
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" +
// widgetRev for the same reason as muted and transition above: a widget's identity does not
// change when it is EDITED, so a content edit produced a byte-identical signature, the
// update was de-duped, and the player kept its old items — including the old rev, so the
// render URL never changed and the WebView reuse held. The screen only caught up on an app
// restart. Found on the emulator; the code read looked correct without it.
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + it.widgetRev + "|" + (if (it.muted) "m" else "") + "|" +
it.schedules.joinToString(";") { b ->
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
} + "|" + (it.transition?.sig() ?: "")
@ -216,8 +234,17 @@ class PlaylistController(
// In solo playback, don't interrupt it: keep it up, stash the new list, and rotate out on the
// next natural advance (video end / image duration). Excludes wallFollower + group-sync, whose
// advance is driven by their tick, not next() — deferring there would strand the swap.
if (isRunning && !wallFollower && hasContentOnScreen && currentlyPlayingId != null &&
newItems.none { it.contentId == currentlyPlayingId }) {
// An EMPTY new list is never a deferral candidate. Clearing a screen's playlist is an
// explicit "stop showing that" from an operator, not an item rotating out — deferring it
// meant selecting "no playlist" left the old content up indefinitely, which is the opposite
// of what was asked for and looked like the setting had done nothing.
if (PendingSwap.shouldDefer(
isRunning = isRunning,
wallFollower = wallFollower,
hasContentOnScreen = hasContentOnScreen,
currentlyPlayingId = currentlyPlayingId,
newContentIds = newItems.map { it.contentId },
)) {
var succ: String? = null
if (items.isNotEmpty()) {
for (k in 1..items.size) {
@ -228,11 +255,13 @@ class PlaylistController(
pendingItems = newItems
pendingSuccessorId = succ
Log.i("PlaylistController", "Current item removed but still live — deferring rotation-out (successor=$succ)")
armPendingSwapDeadline()
return
}
// A non-deferred structural update supersedes any pending swap.
pendingItems = null
pendingSuccessorId = null
cancelPendingSwapDeadline()
items.clear()
items.addAll(newItems)
@ -289,7 +318,16 @@ class PlaylistController(
if (firstActiveIndex() < 0) { showNothingScheduled(); return }
// Screen-resilience: only start on an item whose content is downloaded; if the scheduled
// content isn't ready yet, keep current/wait (never blank on a loading state).
val idx = PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) }
// #234: continue where we left off when this is a reload moments after playing (a new
// Activity => a brand-new controller), not a genuine cold start. Scanning from 0 every time
// is what pinned these playlists on their first item forever.
val saved = try { loadResume?.invoke() } catch (_: Throwable) { null }
val from = PlaybackResume.resumeIndex(
saved?.first ?: -1, saved?.second ?: 0L, System.currentTimeMillis(), items.size)
val idx = if (from > 0 && playableNow(from)) from
else if (from > 0) PlaylistSelection.nextPlayableIndex(items.size, from - 1) { playableNow(it) }
else PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) }
if (from > 0) Log.i("PlaylistController", "Resuming at index $from (reload within resume window)")
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
}
@ -317,6 +355,7 @@ class PlaylistController(
isRunning = false
cancelAdvance()
cancelRetry()
cancelPendingSwapDeadline() // else a stopped controller can still fire next()
hasContentOnScreen = false
pendingItems = null
pendingSuccessorId = null
@ -330,6 +369,7 @@ class PlaylistController(
// Swap in the stashed list now and continue at the preserved successor (or first playable).
pendingItems?.let { p ->
pendingItems = null
cancelPendingSwapDeadline()
val succ = pendingSuccessorId; pendingSuccessorId = null
items.clear(); items.addAll(p)
if (items.isEmpty()) { currentIndex = -1; cancelAdvance(); onPlaylistEmpty(); return }
@ -360,11 +400,49 @@ class PlaylistController(
next()
}
/**
* Items whose turn ends on a TIMER rather than a completion callback.
*
* video/youtube belongs here and did not: it is played by loading an embed into a WebView, which
* fires no completion event, so nothing ever advanced past it. playYoutube() even takes a
* durationSec and never reads it. A playlist containing a YouTube item simply stopped there.
*
* That also stranded #157's deferred swap, which waits for "the next natural advance": assigning
* a different playlist while a YouTube item was on screen deferred forever, so the change looked
* like it had been ignored. Reported as "I assigned Playlist 2 and it kept showing the video".
*
* Local and remote non-YouTube video stay off this list ExoPlayer reports STATE_ENDED and
* onVideoComplete drives those, and a timer would cut a clip short.
*/
private var pendingSwapRunnable: Runnable? = null
/** Apply a deferred swap even if no advance arrives — see PENDING_SWAP_DEADLINE_MS. */
private fun armPendingSwapDeadline() {
cancelPendingSwapDeadline()
pendingSwapRunnable = Runnable {
if (pendingItems != null) {
Log.w("PlaylistController", "Deferred playlist swap never got an advance — applying it now")
next()
}
}
handler.postDelayed(pendingSwapRunnable!!, PendingSwap.DEADLINE_MS)
}
private fun cancelPendingSwapDeadline() {
pendingSwapRunnable?.let { handler.removeCallbacks(it) }
pendingSwapRunnable = null
}
private fun endsOnTimer(item: PlaylistItem): Boolean =
ItemTiming.endsOnTimer(item.mimeType, item.isWidget)
private fun playCurrentItem() {
cancelAdvance()
cancelRetry()
val item = currentItem ?: return
itemStartedAt = System.currentTimeMillis()
// #234: remember where we are so a controller rebuilt seconds from now can carry on.
try { saveResume?.invoke(currentIndex, itemStartedAt) } catch (_: Throwable) {}
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
onItemChanged(item)
hasContentOnScreen = true // a valid item is now rendered — protect it from being blanked
@ -380,7 +458,7 @@ class PlaylistController(
// For images and widgets, auto-advance after duration. For videos, wait
// for the completion callback. Wall followers never auto-advance — the
// leader's wall:sync index drives every switch.
if (!wallFollower && (item.mimeType.startsWith("image/") || item.isWidget)) {
if (!wallFollower && endsOnTimer(item)) {
// slotMs() floors a zero/negative duration to 10s (the max(1, duration||10)
// contract shared with the web/Tizen players). A raw durationSec*1000 here let a
// solo fullscreen widget with duration_sec=0 schedule a 0ms advance -> self-loop.
@ -466,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 }
/**
@ -44,3 +77,94 @@ object PlaylistSelection {
fun whenNonePlayable(hasContentOnScreen: Boolean): NonePlayable =
if (hasContentOnScreen) NonePlayable.KEEP_CURRENT else NonePlayable.SHOW_WAITING
}
/**
* #234 where playback should RESUME when a playlist is (re)loaded.
*
* PlaylistController is created fresh with every MainActivity instance, so a recreate always handed
* it an empty list and then a full one, which reads as "0 -> N items" and starts from the top. On a
* panel that re-registers and relaunches itself at each item boundary, item 2 therefore never
* survived more than a fraction of a second: the reporter of #234 had "never seen the photo, just
* the video", and prod play_logs showed the second item logging 0-1s durations while the first
* accumulated all the playtime.
*
* Starting from the top is only correct for a genuinely COLD start. If we were playing moments ago,
* the right thing is to carry on. Kept pure so the window arithmetic is testable without a device.
*/
object PlaybackResume {
/** How recently we must have been playing for a reload to count as a continuation. */
const val RESUME_WINDOW_MS = 90_000L
/**
* Index to begin scanning from. [savedIndex] < 0, an empty/short playlist, a stale save, or a
* clock that jumped backwards all fall back to 0 i.e. to today's behaviour, so a real cold
* start is unaffected.
*/
fun resumeIndex(savedIndex: Int, savedAtMs: Long, nowMs: Long, itemCount: Int): Int {
if (itemCount <= 0) return 0
if (savedIndex < 0 || savedIndex >= itemCount) return 0
if (savedAtMs <= 0L) return 0
val age = nowMs - savedAtMs
if (age < 0L || age > RESUME_WINDOW_MS) return 0
return savedIndex
}
}
/**
* #157's deferral: when a playlist update drops the item that is CURRENTLY on screen, we let that
* item finish its turn instead of yanking it, and apply the new list at the next natural advance.
*
* The rule needs two guards it did not have, both found from a customer report where a playlist
* change appeared to be ignored entirely:
*
* 1. An EMPTY new list is not a rotation. Clearing a screen's playlist is an operator saying "stop
* showing that", so it must take effect now. Deferring it left the old content up forever.
* 2. Deferring assumes an advance is coming. A YouTube item never advanced (see endsOnTimer), so
* the pending swap was stranded permanently the caller must pair this with a deadline.
*
* Pure so the rule can be checked without a device or a WebView.
*/
object PendingSwap {
/**
* How long a deferred swap may wait for "the next natural advance" before it is applied anyway.
* The deferral assumes an advance is coming; YouTube proved it might not be, and any future item
* type that ends on a callback could do the same. Must comfortably clear an ordinary dwell so it
* never pre-empts a normal rotation, while still being short enough that an operator watching
* the screen sees their change land.
*/
const val DEADLINE_MS = 60_000L
/**
* Whether a playlist update should wait for the current item to finish.
* False means apply it immediately.
*/
fun shouldDefer(
isRunning: Boolean,
wallFollower: Boolean,
hasContentOnScreen: Boolean,
currentlyPlayingId: String?,
newContentIds: List<String>,
): Boolean {
if (!isRunning || wallFollower || !hasContentOnScreen) return false
if (currentlyPlayingId == null) return false
if (newContentIds.isEmpty()) return false // guard 1: an explicit stop
return !newContentIds.contains(currentlyPlayingId)
}
}
/**
* Which items end on a TIMER versus a completion callback.
*
* video/youtube was in neither camp and so ended on nothing at all: it is played by loading an embed
* into a WebView, which reports no completion, and no advance was ever armed for it. The item's
* configured duration was passed to the player and dropped on the floor. A playlist containing a
* YouTube item simply stopped there for good, and any pending playlist change stopped with it.
*
* Local and remote video deliberately stay OFF the timer path the player reports STATE_ENDED for
* those and a timer would cut a clip short at its configured duration.
*/
object ItemTiming {
fun endsOnTimer(mimeType: String, isWidget: Boolean): Boolean =
mimeType.startsWith("image/") || isWidget || mimeType == "video/youtube"
}

View file

@ -43,7 +43,12 @@ object ScheduleEval {
val nowMin = zdt.hour * 60 + zdt.minute
val date = zdt.toLocalDate()
blocks.any { blockMatches(it, dow, nowMin, date) }
} catch (e: Exception) {
} catch (e: Throwable) {
// Throwable, not Exception. A missing java.time on an old API level surfaces as
// NoClassDefFoundError — an Error — which sailed straight through a catch(Exception)
// and turned this "fail open, a blank screen is worse than an over-running promo"
// contract into its exact opposite: nothing played at all. Desugaring (see
// build.gradle.kts) is the real fix; this makes the guard mean what it says.
true // fail open
}
}

View file

@ -51,6 +51,9 @@ class ZoneManager(
var currentLayoutId: String? = null
private set
var lastAssignmentSig: String? = null
// Geometry of the zones currently built. Editing a layout in place keeps its id, so the id
// alone cannot tell "same layout, same zones" from "same layout, zones changed".
var lastZoneSig: String? = null
// #74/#75: device-effective IANA timezone for per-item schedule evaluation.
@Volatile private var effectiveTimezone: String? = null
@ -207,8 +210,12 @@ class ZoneManager(
widgetType != null -> {
val widgetId = a.optString("widget_id", "")
val webView = createWebView()
// rev, exactly as the fullscreen path does: a widget's id does not change when it
// is edited, so without it a zone kept rendering the old content indefinitely.
val wRev = a.optLong("widget_rev", 0L)
val wUrl = "$renderServerUrl/api/widgets/$widgetId/render" +
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "")
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "?d=") +
"&rev=" + wRev
webView.loadUrl(wUrl)
webView.layoutParams = params
container.addView(webView); zoneViews[zone.id] = webView
@ -245,6 +252,14 @@ class ZoneManager(
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) handler.post { advance() }
}
// Same reason MediaPlayerManager treats a playback error as a completion
// ("Root-2: a corrupt/undecodable video used to freeze the playlist
// forever"): an error lands in STATE_IDLE, never STATE_ENDED, so without
// this the zone stops rotating and goes black until the layout changes or
// the app restarts — while every other zone keeps going.
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
handler.post { advance() }
}
})
prepare()
playWhenReady = true

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

@ -0,0 +1,28 @@
package com.remotedisplay.player.service
/**
* #234 follow-up: how often a device may ask the server to re-send its playlist.
*
* "Refresh" is not cheap. requestPlaylistRefresh() emits a full `device:register`, and the server's
* register handler runs 7+ statements plus the whole fingerprint/identity path and rebuilds the
* playlist payload. PlaylistController.next() was calling it on EVERY item advance, so a panel on a
* 10-second image re-registered six times a minute, forever and each reply pushed a full playlist
* back down, which is what kept feeding the restart loop behind #234.
*
* It was also redundant: the heartbeat already refreshes every 4th beat (60s), so the periodic pull
* this was duplicating exists either way. Throttling at the single chokepoint keeps every caller's
* intent recovery paths still refresh, they just cannot stack up without having to rank them.
*
* Pure so the interval arithmetic is testable without a device or a socket.
*/
object RefreshThrottle {
/** Just under the heartbeat's own 60s pull, so the two interleave instead of cancelling out. */
const val MIN_INTERVAL_MS = 55_000L
fun shouldRefresh(lastAtMs: Long, nowMs: Long): Boolean {
if (lastAtMs <= 0L) return true // never refreshed — always allow the first
val since = nowMs - lastAtMs
if (since < 0L) return true // clock corrected backwards; never wedge on it
return since >= MIN_INTERVAL_MS
}
}

View file

@ -38,6 +38,8 @@ class UpdateChecker(private val context: Context) {
private val CHECK_INTERVAL = 30 * 60 * 1000L
private var installReceiverRegistered = false
// Held so shutdown() can unregister it; without a handle the receiver outlives the Activity.
private var installReceiver: BroadcastReceiver? = null
// #139: report OTA status to the dashboard (device:log, tag "ota"). Wired by MainActivity
// to WebSocketService.sendLog; null until then. Read lazily so binding order doesn't matter.
@ -45,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) {}
@ -92,6 +106,7 @@ class UpdateChecker(private val context: Context) {
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
}
installReceiverRegistered = true
installReceiver = receiver
}
fun startPeriodicCheck() {
@ -113,6 +128,25 @@ class UpdateChecker(private val context: Context) {
checkTimer = null
}
/**
* Full teardown for an Activity that is going away.
*
* stopPeriodicCheck alone leaves the install receiver registered against a dead Context, and
* installReceiverRegistered is per-instance so each Activity recreate produced another
* checker polling /api/update/check and another receiver for INSTALL_COMPLETE. N of those means
* one STATUS_PENDING_USER_ACTION fires N confirm dialogs over customer content, and concurrent
* checkers race in tryPackageInstaller, which begins by abandoning ALL of this app's installer
* sessions so one can abandon another's staged session mid-flight and the update never lands.
*/
fun shutdown() {
stopPeriodicCheck()
if (installReceiverRegistered) {
installReceiver?.let { r -> try { context.unregisterReceiver(r) } catch (_: Throwable) { /* already gone */ } }
installReceiver = null
installReceiverRegistered = false
}
}
/**
* [forced] = an operator pressed "force update" on this specific device, rather than the
* 30-minute timer firing. A forced run differs in three ways, all because a human aimed it at
@ -250,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
}
@ -289,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
@ -310,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
}
@ -329,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
@ -342,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
}
@ -356,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 }
@ -460,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
@ -476,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
}
@ -489,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
}
@ -511,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

@ -432,6 +432,18 @@ class WebSocketService : Service() {
handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } }
}
// A PIN set or rotated from the dashboard takes effect NOW, not at the next pairing.
// Without this an operator who rotated a leaked PIN would believe they had revoked
// access while the old one still opened the menu — worse than not offering it.
safeOn("device:settings-pin") { args ->
val data = args.getOrNull(0) as? org.json.JSONObject ?: return@safeOn
val pin = data.optString("settings_pin", "")
if (pin.isNotEmpty()) {
config.settingsPin = pin
Log.i("WebSocketService", "Settings PIN updated from dashboard") // never log the PIN
}
}
safeOn("device:playlist-update") { args ->
val data = args.firstOrNull() as? JSONObject ?: run {
Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}")
@ -584,8 +596,18 @@ class WebSocketService : Service() {
}
} catch (e: Throwable) { Log.e("WebSocketService", "screen_off: ${e.message}") }
}
// No privileged wake on a non-rooted panel (keyevent 224 was denied); retired.
"screen_on" -> Log.w("WebSocketService", "screen_on: no privileged wake path — no-op")
// Was a no-op because `input keyevent 224` is denied to an app UID — but a
// wake LOCK is a different mechanism needing only WAKE_LOCK, which we hold.
// Handled here as well as in MainActivity so a panel whose Activity is not
// foregrounded can still be woken; the service is the only thing guaranteed
// to be alive, and "screen won't come back on" means a site visit.
"screen_on" -> {
val woke = com.remotedisplay.player.system.SystemControl(applicationContext).wakeScreen()
Log.i("WebSocketService", "screen_on: wake=$woke")
// Bring the player back in front of the keyguard too. Same fail-loud
// reasoning as Relauncher: waking to a lock screen is only half a fix.
handler.post { try { onCommand?.invoke("screen_on", payload) } catch (_: Throwable) {} }
}
"set_debug" -> {
val on = payload?.optBoolean("enabled", false) ?: false
// Point the sink at this socket, then flip the flag. When on,
@ -645,6 +667,12 @@ class WebSocketService : Service() {
put("client_version", deviceInfo.getAppVersion())
put("platform", "Android " + android.os.Build.VERSION.RELEASE)
put("contract_version", "v4")
// What this panel can actually do, so the dashboard stops offering controls that
// cannot work on it. Recomputed on EVERY register rather than cached: accessibility
// gets switched on months after install, device owner arrives via provisioning, and
// WRITE_SETTINGS can be revoked — a value captured once would be wrong on the same
// hardware from one boot to the next.
put("capabilities", com.remotedisplay.player.telemetry.PlayerCapabilities.declare(this@WebSocketService))
} catch (e: Throwable) { Log.w("WebSocketService", "identity: ${e.message}") }
}
@ -729,6 +757,21 @@ class WebSocketService : Service() {
/** True from the first server rejection until the device is (re)paired — UI stays on re-pair. */
fun isAwaitingRepair(): Boolean = awaitingRepair
/**
* Why the server last refused us, verbatim from device:auth-error (e.g. "Device blocked").
* The server always says why; the player used to throw it away and fall back to a generic
* connection failure, so an operator block read as "couldn't reach the server, check the url"
* and sent people off debugging their network. #234.
*/
@Volatile var lastRejectionReason: String? = null
/**
* True when the last rejection came with a settle window the server is asking us to wait and
* try again, not telling us we are gone. This service already holds, retries once and recovers
* on its own, so a listener must not tear the player down over it.
*/
@Volatile var lastRejectionTransient: Boolean = false
private set
/** Milliseconds left in the reclaim-settle hold (0 once elapsed) — drives the UI countdown. */
fun repairHoldRemainingMs(): Long = maxOf(0L, repairHoldUntilMs - SystemClock.elapsedRealtime())
/** True only when the shown pairing code is server-accepted (pairable) — not a rejected/stale one. */
@ -748,7 +791,9 @@ class WebSocketService : Service() {
* scheduled retry, so the screen is stable no register/reject/register churn.
*/
private fun handleServerRejection(reason: String) {
lastRejectionReason = reason
val settleSec = parseSettleSeconds(reason)
lastRejectionTransient = settleSec > 0
Log.w("WebSocketService", "Server rejected device ($reason) — settle=${settleSec}s")
pairingCodeLive = false // this registration was rejected — the local code is NOT pairable
config.clearDeviceCredentials()
@ -778,6 +823,7 @@ class WebSocketService : Service() {
/** Re-pair complete (device:paired, or a normal authenticated reconnect) — clear all repair state. */
private fun resetRepairBackoff() {
lastRejectionReason = null
repairRetryPending = false
repairBackoffMs = 0L
awaitingRepair = false
@ -845,8 +891,17 @@ class WebSocketService : Service() {
connect()
}
@Volatile private var lastRefreshAt = 0L
fun requestPlaylistRefresh() {
if (socket?.connected() != true || config.deviceId.isEmpty()) return
// #234 follow-up: this emits a FULL device:register (7+ server statements + the identity
// path + a playlist rebuild), and PlaylistController.next() calls it on every item advance.
// A 10-second image therefore re-registered six times a minute. The heartbeat already pulls
// a fresh playlist every 60s, so the per-item call bought nothing and cost a great deal.
val now = System.currentTimeMillis()
if (!RefreshThrottle.shouldRefresh(lastRefreshAt, now)) return
lastRefreshAt = now
Log.i("WebSocketService", "Requesting playlist refresh")
try {
val data = org.json.JSONObject().apply {

View file

@ -94,5 +94,42 @@ class SystemControl(private val context: Context) {
} catch (e: Throwable) { Log.w(TAG, "setScreenOffTimeout: ${e.message}"); false }
}
/**
* Wake the panel. The other half of screen_off, which had none.
*
* screen_off has always worked device owner / admin FORCE_LOCK, or the accessibility lock
* but screen_on was a logged no-op on the belief that a non-rooted panel has no privileged
* wake path. The retired attempt was `input keyevent 224`, which exec denies to an app UID; a
* wake LOCK is a different mechanism and needs only WAKE_LOCK, a normal permission we already
* hold. So the conclusion ("no wake path") was drawn from one failed approach.
*
* That asymmetry is worse than it sounds on a signage fleet: an operator turns a panel off for
* the night and cannot turn it back on remotely, so someone drives to the site. Losing the
* screen is the expensive direction to fail in.
*
* ACQUIRE_CAUSES_WAKEUP + SCREEN_BRIGHT is deprecated (API 17) and still the only app-level
* wake there is; on a device that ignores it we return false rather than pretending. Held
* briefly and released an indefinite wake lock would pin the panel on and defeat every
* screen-off command that follows.
*/
fun wakeScreen(holdMs: Long = 3000L): Boolean = try {
val pm = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
@Suppress("DEPRECATION")
val lock = pm.newWakeLock(
android.os.PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP or
android.os.PowerManager.ON_AFTER_RELEASE,
"screentinker:wake"
)
// Time out on its own as well as being released below: if the release is ever missed, a
// self-expiring lock still lets the panel sleep instead of burning in.
lock.acquire(holdMs)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try { if (lock.isHeld) lock.release() } catch (_: Throwable) { }
}, holdMs)
Log.i(TAG, "wakeScreen: wake lock acquired for ${holdMs}ms")
true
} catch (e: Throwable) { Log.w(TAG, "wakeScreen: ${e.message}"); false }
companion object { private const val TAG = "SystemControl" }
}

View file

@ -30,6 +30,15 @@ class DeviceInfo(private val context: Context) {
put("ram_total_mb", getRamTotalMB())
put("cpu_usage", getCpuUsage())
put("wifi_ssid", getWifiSSID())
// The screen's OWN address on the network. The server separately records the PUBLIC
// address it sees the connection from; showing only that had customers reading their
// 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)
@ -102,8 +111,12 @@ class DeviceInfo(private val context: Context) {
Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_OFF_TIMEOUT, 0)
} catch (_: Throwable) { 0 }
/** #160: is OUR accessibility service currently enabled (drives remote-control availability). */
private fun isAccessibilityEnabled(): Boolean = try {
/**
* #160: is OUR accessibility service currently enabled (drives remote-control availability).
* Internal rather than private because the capability declaration asks the same question, and
* a second copy of this check would drift from the telemetry the dashboard shows beside it.
*/
internal fun isAccessibilityEnabled(): Boolean = try {
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as android.view.accessibility.AccessibilityManager
val mine = android.content.ComponentName(context,
@ -168,17 +181,85 @@ class DeviceInfo(private val context: Context) {
}
}
/**
* The connected Wi-Fi network name, or a value saying WHY we do not have it.
*
* Android 8.1+ hides the SSID from apps without location permission, returning the literal
* "<unknown ssid>". We report "Unknown" for that, which reads as a fault in the player a
* customer reasonably assumed it needed device-owner access. It needs LOCATION, which this app
* deliberately does not require: a signage player asking for location to display a network name
* is a poor trade. It can be granted from the setup screen if someone wants the field filled in.
*
* So: "permission" when we are not allowed to know, null when there is genuinely no Wi-Fi (an
* Ethernet panel), and the name otherwise. The dashboard can then say something true.
*/
@Suppress("DEPRECATION")
private fun getWifiSSID(): String {
private fun getWifiSSID(): String? {
return try {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val info = wm.connectionInfo
info.ssid?.replace("\"", "") ?: "Unknown"
val raw = wm.connectionInfo?.ssid?.replace("\"", "")
when {
raw.isNullOrEmpty() -> null
// What the platform hands back when location is missing or switched off.
raw.equals("<unknown ssid>", ignoreCase = true) || raw == "0x" -> "permission"
else -> raw
}
} catch (e: Exception) {
"Unknown"
null
}
}
/** First non-loopback IPv4 on any up interface (Wi-Fi or Ethernet). No permission needed. */
private fun getLocalIp(): 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.isLoopbackAddress && addr is java.net.Inet4Address) { found = addr.hostAddress; break }
}
}
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

@ -0,0 +1,144 @@
package com.remotedisplay.player.telemetry
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.Log
import com.remotedisplay.player.admin.STPolicy
import org.json.JSONArray
/**
* What THIS panel can actually do, right now.
*
* The dashboard used to offer every control to every display, so buttons that could never work on
* a given panel sat there and did nothing when pressed. The server-side vocabulary lives in
* `server/lib/player-capabilities.js`; these strings must match it exactly, because an unknown
* name is dropped on arrival and a renamed one silently removes a control from every panel still
* reporting the old spelling.
*
* Computed at REGISTRATION, not build time. Almost everything interesting here is runtime state
* an APK cannot know about itself: accessibility gets switched on months after install, device
* owner is granted by a provisioning flow, WRITE_SETTINGS is a per-device grant an operator may
* revoke. A static list would be wrong on the same hardware from one boot to the next.
*
* The rule when uncertain is to UNDER-claim. A missing control is a support question; a control
* that appears to work and does nothing is a bug report, and on a panel nobody can reach it is an
* expensive one.
*/
object PlayerCapabilities {
/**
* The capability set for this device, as a JSON array ready to attach to the register payload.
* Never throws: a failure here must not cost the panel its registration, so the worst case is
* an empty declaration, which the server reads as "declares nothing meaningful".
*/
fun declare(context: Context): JSONArray {
val caps = mutableListOf<String>()
try {
val policy = STPolicy(context)
val isOwner = policy.isDeviceOwner()
val canInstall = policy.canInstallSilently()
val canWriteSettings = try { Settings.System.canWrite(context) } catch (_: Throwable) { false }
val accessibility = DeviceInfo(context).isAccessibilityEnabled()
// ---- always true on the Android player -------------------------------------------------
// Every content type the playlist engine renders, plus the layout features built on it.
caps += listOf(
"playback.video", "playback.image", "playback.widget", "playback.youtube",
"playback.zones", "playback.transitions", "playback.pip",
// Mute reaches the YouTube embed through the IFrame API bridge, not just <video>,
// so this is a real claim rather than the half-truth the browser players carried.
"audio.mute", "audio.volume",
// 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.
"system.restart_player", "system.self_update",
// Clock-derived group sync is platform-independent.
"sync.clock",
// Content is cached to local storage and survives a server outage.
"offline.cache",
// App-UID `sh -c`. Deliberately NOT gated on device owner: it runs at any tier and
// is the diagnostic path the dashboard already relies on. Gated server-side instead.
"system.shell"
)
// ---- conditional on runtime state -------------------------------------------------------
// 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
// path — offering a control that sleeps a panel it cannot wake would be the worst
// possible version of this feature.
if (isOwner || policy.isAdminActive() || accessibility) caps += "display.power"
// Owner-only reboot. Off-owner it degrades to an accessibility power DIALOG, which needs
// someone standing at the screen — not a remote capability.
if (isOwner) caps += "system.reboot"
// Silent lock-task. Off-owner startLockTask() gives screen pinning, which prompts for
// 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"
// Silent install: device owner, or a foreign DPC that delegated the install scope.
if (canInstall) caps += "system.install_apk"
// System-wide brightness and screen-off timeout: WRITE_SETTINGS, or an owner writing the
// setting directly. Per-window dimming works at any tier but is not what the operator
// means by "brightness", so it does not earn the claim on its own.
if (canWriteSettings || isOwner) caps += listOf("system.brightness", "system.screen_timeout")
Log.i(TAG, "Capabilities: ${caps.size} declared (owner=$isOwner install=$canInstall " +
"writeSettings=$canWriteSettings a11y=$accessibility)")
} catch (e: Throwable) {
// An empty array is honest here. Falling back to "everything" would put us straight back
// to buttons that do nothing, which is the failure this whole model exists to remove.
Log.w(TAG, "Capability detection failed: ${e.message}")
}
return JSONArray(caps)
}
private const val TAG = "PlayerCapabilities"
}
/*
* Deliberately NOT declared on Android, so the dashboard stops offering them:
*
* display.resolution Setting the output mode needs system/root. The panel runs at whatever the
* display negotiated and an app cannot change it.
* sync.native Frame-accurate hardware sync is a BrightSign SyncManager feature. Android's
* clock-derived group sync is declared instead, which is what it actually has.
*/

View file

@ -446,6 +446,66 @@
android:paddingBottom="4dp" />
</LinearLayout>
<!-- OPTIONAL: Wi-Fi network name. Android 8.1+ will not tell an app the connected SSID
without location permission, so the device page shows "unavailable" without this.
Nothing else in the player uses location, and nothing else changes if it is refused —
this row exists so it is a choice rather than a silent blank field. -->
<LinearLayout
android:id="@+id/locationRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Wi-Fi Name (optional)"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Android needs location permission to reveal the network name. Nothing else uses it."
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/locationStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableLocationBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Default launcher / HOME. A signage kiosk MUST be the device's default launcher, or Android
keeps returning to the stock launcher and the player is torn down + recreated on a loop
(never renders). Not applicable where you can't set a launcher (e.g. some Android TV). -->

View file

@ -4,4 +4,6 @@
<string name="accessibility_description">RemoteDisplay nutzt die Bedienungshilfen, um Fernsteuerung der Stromzufuhr und Systemnavigation zu ermöglichen.</string>
<string name="nothing_scheduled">Derzeit ist nichts geplant</string>
<string name="waiting_for_content">Warte auf Inhalte…</string>
<string name="device_blocked_status">Dieser Bildschirm wurde im Dashboard gesperrt</string>
<string name="device_unpaired_status">Dieser Bildschirm wurde entkoppelt — warte auf erneute Kopplung</string>
</resources>

View file

@ -4,4 +4,6 @@
<string name="accessibility_description">RemoteDisplay usa accesibilidad para habilitar el control remoto de encendido y la navegación del sistema.</string>
<string name="nothing_scheduled">No hay nada programado en este momento</string>
<string name="waiting_for_content">Esperando contenido…</string>
<string name="device_blocked_status">Esta pantalla ha sido bloqueada en el panel</string>
<string name="device_unpaired_status">Esta pantalla se desvinculó — esperando volver a vincularse</string>
</resources>

View file

@ -4,4 +4,6 @@
<string name="accessibility_description">RemoteDisplay utilise l\'accessibilité pour activer les contrôles d\'alimentation à distance et la navigation système.</string>
<string name="nothing_scheduled">Rien de programmé pour le moment</string>
<string name="waiting_for_content">En attente de contenu…</string>
<string name="device_blocked_status">Cet écran a été bloqué dans le tableau de bord</string>
<string name="device_unpaired_status">Cet écran a été dissocié — en attente d\'un nouvel appairage</string>
</resources>

View file

@ -4,4 +4,6 @@
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
<string name="device_blocked_status">यह स्क्रीन डैशबोर्ड में अवरोधित कर दी गई है</string>
<string name="device_unpaired_status">यह स्क्रीन अनयुग्मित हो गई — फिर से युग्मित होने की प्रतीक्षा</string>
</resources>

View file

@ -4,4 +4,6 @@
<string name="accessibility_description">RemoteDisplay usa acessibilidade para habilitar controles remotos de energia e navegação do sistema.</string>
<string name="nothing_scheduled">Nada programado no momento</string>
<string name="waiting_for_content">Aguardando conteúdo…</string>
<string name="device_blocked_status">Este ecrã foi bloqueado no painel</string>
<string name="device_unpaired_status">Este ecrã foi desemparelhado — a aguardar novo emparelhamento</string>
</resources>

View file

@ -25,6 +25,8 @@
<string name="hw_enroll_constraints">Or scan the provisioning QR from the dashboard after a factory reset (tap the setup-wizard Welcome screen 6×). Device owner is optional — the app works fully without it.</string>
<string name="hw_recheck">Re-check</string>
<string name="settings_info_device">Device</string>
<string name="settings_exit_kiosk">Exit kiosk mode</string>
<string name="settings_exit_kiosk_done">Kiosk mode off. It stays off until re-enabled from the dashboard.</string>
<string name="settings_exit">Exit app</string>
<string name="settings_save">Save</string>
<string name="settings_exit_title">Exit ScreenTinker?</string>
@ -39,4 +41,8 @@
<string name="settings_perm_notifications">Notifications</string>
<string name="settings_perm_hint">Tap \"Open\" to manage permissions in system settings</string>
<string name="settings_perm_open">Open settings</string>
<string name="settings_perm_done">Done</string>
<string name="settings_perm_manage">Manage permissions</string>
<string name="device_blocked_status">This screen has been blocked in the dashboard</string>
<string name="device_unpaired_status">This screen was unpaired — waiting to be re-paired</string>
</resources>

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

@ -2,7 +2,9 @@ package com.remotedisplay.player.data
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@ -14,11 +16,13 @@ import java.nio.file.Files
import java.util.concurrent.TimeUnit
/**
* Root-2 REPRODUCE-THEN-PROVE for the "stuck downloading / frozen" caching bug. Each test drives
* the REAL ContentCache.downloadContent against a local HTTP server that reproduces a specific
* failure mode on a HEALTHY socket (the socket is fine the DOWNLOAD misbehaves), and proves the
* fix: a stalled/trickling download aborts instead of hanging forever, and a truncated body is
* never promoted to the cache (so it can't be played as if whole and wedge the playlist).
* Root-2 REPRODUCE-THEN-PROVE for the "stuck downloading / frozen" caching bug, extended with the
* RESUME case that came out of a customer on an unstable one-bar 5G link: their screens showed the
* waiting state instead of playing, and the reason was not the playback path at all the asset
* could never finish downloading, so there was never anything cached to play.
*
* Each test drives the REAL ContentCache against a local HTTP server that reproduces a specific
* failure mode on a HEALTHY socket (the socket is fine the DOWNLOAD misbehaves).
*
* The client uses short timeouts so the STALL reproduction is fast; the download/validation logic
* exercised is identical to production (only the timeout VALUES differ production is
@ -62,13 +66,79 @@ class ContentDownloadTest {
return "http://127.0.0.1:${s.localPort}"
}
/** The request headers of each call the client made, in order. */
private val seen = java.util.Collections.synchronizedList(ArrayList<Map<String, String>>())
/**
* A server that behaves like a bad link: it honours Range/If-Range correctly, but never sends
* more than [bytesPerCall] before dropping the connection mid-body. Nothing is wrong with the
* server or the file the transfer simply cannot complete in one call, which is the whole
* shape of the reported fault.
*
* [etagOf] is read per request so a test can change the asset underneath a resume.
*/
private fun serveFlaky(body: () -> ByteArray, bytesPerCall: Int, etagOf: () -> String): String {
val s = ServerSocket(0)
server = s
Thread {
while (!s.isClosed) {
try {
s.accept().use { sock ->
val headers = HashMap<String, String>()
val reader = sock.getInputStream().bufferedReader()
reader.readLine() // request line
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break
val i = line.indexOf(':')
if (i > 0) headers[line.substring(0, i).trim().lowercase()] = line.substring(i + 1).trim()
}
seen.add(headers)
val content = body()
val etag = etagOf()
val out = sock.getOutputStream()
val range = headers["range"]
val ifRange = headers["if-range"]
// If-Range with a stale validator means "send the whole thing" — the
// mechanism that stops a resume splicing two different assets together.
val honourRange = range != null && (ifRange == null || ifRange == etag)
val start = if (honourRange) Regex("""bytes=(\d+)-""").find(range!!)?.groupValues?.get(1)?.toInt() ?: 0 else 0
if (honourRange && start >= content.size) {
out.write("HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */${content.size}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray())
out.flush()
return@use
}
val remaining = content.size - start
if (honourRange) {
out.write(("HTTP/1.1 206 Partial Content\r\n" +
"Content-Range: bytes $start-${content.size - 1}/${content.size}\r\n" +
"Content-Length: $remaining\r\nETag: $etag\r\nConnection: close\r\n\r\n").toByteArray())
} else {
out.write(("HTTP/1.1 200 OK\r\nContent-Length: ${content.size}\r\n" +
"ETag: $etag\r\nConnection: close\r\n\r\n").toByteArray())
}
// ...then send only part of what we just declared, and hang up.
val send = minOf(bytesPerCall, remaining)
out.write(content, start, send)
out.flush()
}
} catch (_: Exception) { /* closed between tests */ }
}
}.apply { isDaemon = true; start() }
return "http://127.0.0.1:${s.localPort}"
}
private fun OutputStream.writeHttp(contentLength: Int, body: ByteArray) {
write("HTTP/1.1 200 OK\r\nContent-Length: $contentLength\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray())
write("HTTP/1.1 200 OK\r\nContent-Length: $contentLength\r\nETag: \"w1\"\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray())
write(body)
flush()
}
private fun partFiles() = dir.listFiles { _, name -> name.endsWith(".part") }?.toList() ?: emptyList()
private fun tagFiles() = dir.listFiles { _, name -> name.endsWith(".part.tag") }?.toList() ?: emptyList()
// ---- positive control: a complete download IS cached ----
@Test fun `complete download is cached with the right size and no leftover part file`() {
@ -78,10 +148,11 @@ class ContentDownloadTest {
assertEquals(5L, file!!.length())
assertNotNull(cache.getCachedFile("cidA"))
assertTrue("no .part temp should remain", partFiles().isEmpty())
assertTrue("no validator sidecar should remain", tagFiles().isEmpty())
}
// ---- REPRODUCE: truncated body (declares 100 bytes, sends 40 then closes) on a healthy socket ----
@Test fun `truncated download is NOT promoted to the cache — partial detected and discarded`() {
@Test fun `truncated download is NOT promoted to the cache — but its bytes are KEPT to resume from`() {
val url = serveOnce {
it.writeHttp(100, ByteArray(40) { 'x'.code.toByte() })
// close after 40 of the declared 100 bytes -> truncation
@ -89,13 +160,17 @@ class ContentDownloadTest {
val file = cache.downloadContent(url, "cidB", "clip.bin")
assertNull("a truncated download must return null (not a usable file)", file)
assertNull("a truncated file must NOT be served as cached", cache.getCachedFile("cidB"))
assertTrue("the partial .part must be cleaned up, not left behind", partFiles().isEmpty())
// The bytes stay. Deleting them was correct while there was no way to continue from them
// and catastrophic once the link is the limiting factor: it made every attempt start at
// zero, so an asset larger than one call's worth could never be cached at all.
assertEquals("the 40 received bytes must be kept for the next attempt", 1, partFiles().size)
assertEquals(40L, partFiles().first().length())
}
// ---- REPRODUCE: a STALLED download (headers + a trickle, then hang) on a healthy socket ----
@Test fun `stalled download aborts within the timeout instead of hanging forever`() {
val url = serveOnce {
it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray())
it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\nETag: \"v1\"\r\n\r\n".toByteArray())
it.write(ByteArray(10)); it.flush()
Thread.sleep(10_000) // hang mid-stream — the OLD client (5min readTimeout) waited here
}
@ -105,7 +180,154 @@ class ContentDownloadTest {
assertNull("a stalled download must fail, not hang", file)
assertTrue("must abort quickly via the timeout (was ~$elapsed ms)", elapsed < 5_000)
assertNull(cache.getCachedFile("cidC"))
assertTrue("no partial left behind after a stall", partFiles().isEmpty())
assertEquals("the 10 bytes that did arrive are kept", 10L, partFiles().first().length())
}
// ---- THE BUG: a link that can never carry the whole asset in one call ----
@Test fun `an asset larger than any single call still completes, one resumed attempt at a time`() {
val body = ByteArray(1000) { (it % 251).toByte() }
val url = serveFlaky({ body }, bytesPerCall = 300, etagOf = { "\"v1\"" })
// Each attempt gets 300 bytes and the connection dies. Restart-from-zero would loop here
// forever, cache nothing, and leave the screen on "waiting for content" — the customer's
// report. Resume needs four.
var result: ContentCache.Result = ContentCache.Result.Failed
var attempts = 0
while (attempts < 10) {
attempts++
result = cache.fetch(url, "big", "movie.bin")
if (result is ContentCache.Result.Done) break
assertTrue("every attempt must make progress", (result as ContentCache.Result.Partial).progressed)
}
assertTrue("the asset must eventually be cached, not retried forever", result is ContentCache.Result.Done)
assertEquals(4, attempts)
assertArrayEquals("the reassembled file must be byte-identical to the original",
body, cache.getCachedFile("big")!!.readBytes())
assertTrue("no temp files survive completion", partFiles().isEmpty() && tagFiles().isEmpty())
}
@Test fun `each attempt asks for exactly the bytes it does not have yet`() {
seen.clear()
val body = ByteArray(1000) { (it % 251).toByte() }
val url = serveFlaky({ body }, bytesPerCall = 400, etagOf = { "\"v1\"" })
repeat(3) { cache.fetch(url, "big", "movie.bin") }
assertNull("the first call has nothing to resume from", seen[0]["range"])
assertEquals("bytes=400-", seen[1]["range"])
assertEquals("bytes=800-", seen[2]["range"])
// Without If-Range the server cannot tell us the asset changed, and a resume would append
// the tail of a new file to the head of an old one.
assertEquals("\"v1\"", seen[1]["if-range"])
}
// ---- the corruption a resume could cause, and the guard that stops it ----
@Test fun `an asset that changes under a resume restarts from zero instead of splicing`() {
val v1 = ByteArray(1000) { 'a'.code.toByte() }
val v2 = ByteArray(1000) { 'b'.code.toByte() }
var current = v1
var etag = "\"v1\""
val url = serveFlaky({ current }, bytesPerCall = 400, etagOf = { etag })
cache.fetch(url, "swap", "movie.bin") // 400 bytes of v1 on disk
assertEquals(400L, partFiles().first().length())
current = v2; etag = "\"v2\"" // replaced between attempts
val second = cache.fetch(url, "swap", "movie.bin")
assertTrue(second is ContentCache.Result.Partial)
// If-Range mismatch -> the server sent the WHOLE new asset, so we started over and hold
// 400 bytes of v2, not 400 of v1 with a v2 tail to come. A splice would have been exactly
// 1000 bytes and passed every completeness check we have.
assertEquals(400L, partFiles().first().length())
repeat(3) { cache.fetch(url, "swap", "movie.bin") }
assertArrayEquals("the cached asset must be all-v2, with no v1 bytes spliced in",
v2, cache.getCachedFile("swap")!!.readBytes())
}
@Test fun `a partial longer than the asset is discarded rather than resumed forever`() {
// The server answers 416. Keeping the partial would mean asking for a range past the end on
// every future attempt and never recovering.
val body = ByteArray(100) { 'z'.code.toByte() }
val url = serveFlaky({ body }, bytesPerCall = 500, etagOf = { "\"v1\"" })
java.io.File(dir, "over.bin.part").writeBytes(ByteArray(400))
java.io.File(dir, "over.bin.part.tag").writeText("\"v1\"")
val first = cache.fetch(url, "over", "movie.bin")
assertTrue("an over-long partial is a hard failure, not a resume", first is ContentCache.Result.Failed)
assertTrue("the stale partial must be discarded", partFiles().isEmpty())
assertTrue(cache.fetch(url, "over", "movie.bin") is ContentCache.Result.Done)
assertArrayEquals(body, cache.getCachedFile("over")!!.readBytes())
}
@Test fun `a server that offers no validator discards the partial rather than hoarding it`() {
// No ETag and no Last-Modified: there is nothing to detect a changed asset with, so a
// resume would be a guess and the next attempt has to start over anyway. Bytes that cannot
// be built upon are not progress — keeping them would leave dead weight on disk, and
// COUNTING them as progress would make the coordinator chain attempts against a link that
// is getting nowhere. It backs off like the failure it is.
val s = ServerSocket(0)
server = s
Thread {
while (!s.isClosed) {
try {
s.accept().use { sock ->
val reader = sock.getInputStream().bufferedReader()
while (true) { val line = reader.readLine() ?: break; if (line.isEmpty()) break }
val out = sock.getOutputStream()
out.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\n".toByteArray())
out.write(ByteArray(40)); out.flush()
}
} catch (_: Exception) {}
}
}.apply { isDaemon = true; start() }
val url = "http://127.0.0.1:${s.localPort}"
val r = cache.fetch(url, "noval", "movie.bin")
assertTrue(r is ContentCache.Result.Partial)
assertFalse("re-fetching the same prefix forever is not progress", (r as ContentCache.Result.Partial).progressed)
assertTrue("an unusable partial must not be left on disk", partFiles().isEmpty())
assertNull("and nothing incomplete is ever served as cached", cache.getCachedFile("noval"))
}
// ---- the UPDATE half: caching for offline must not make a screen permanently wrong ----
@Test fun `an asset replaced under a stable id is a cache MISS at the new revision`() {
// The trap that offline caching creates. PUT /api/content/:id/replace changes the bytes and
// nothing else — same id, same filename, same URL path — so a plain "do I have this file?"
// check says yes forever and the panel keeps playing last month's video.
val v1 = ByteArray(50) { 'a'.code.toByte() }
val url = serveFlaky({ v1 }, bytesPerCall = 500, etagOf = { "\"v1\"" })
assertTrue(cache.fetch(url, "swap", "clip.bin", rev = 100L) is ContentCache.Result.Done)
assertTrue("cached at the revision we asked for", cache.isContentCached("swap", 100L))
assertTrue("...and NOT at a newer one", !cache.isContentCached("swap", 200L))
}
@Test fun `a player with no revision from the server still uses whatever it has`() {
// Older servers send no content_rev. Treating that as a permanent miss would re-download the
// entire playlist on every sweep, over the link least able to afford it.
val url = serveOnce { it.writeHttp(4, "abcd".toByteArray()) }
assertNotNull(cache.downloadContent(url, "norev", "clip.bin"))
assertTrue(cache.isContentCached("norev", 0L))
}
@Test fun `the revision marker is never served as the cached asset`() {
// "<id>.<ext>.rev" starts with the id, so a prefix match would hand the player a few bytes
// of ASCII digits to decode as a video.
java.io.File(dir, "marker.bin.rev").writeText("12345")
assertNull(cache.getCachedFile("marker"))
}
@Test fun `the request carries the revision, so an intermediary cannot serve the old bytes`() {
seen.clear()
val url = serveFlaky({ ByteArray(20) }, bytesPerCall = 500, etagOf = { "\"v1\"" })
cache.fetch(url, "cdn", "clip.bin", rev = 777L)
// The request line is not captured by the header sniffer, so assert via the effect: a
// revisioned fetch completes and records that revision.
assertTrue(cache.isContentCached("cdn", 777L))
assertTrue(!cache.isContentCached("cdn", 778L))
}
// ---- prefix cross-match guard: an id that prefixes another must not match ----
@ -116,4 +338,20 @@ class ContentDownloadTest {
assertNotNull(cache.getCachedFile("abc"))
assertNull("id 'ab' must NOT match cached 'abc.x'", cache.getCachedFile("ab"))
}
@Test fun `the validator sidecar is never mistaken for the cached asset`() {
// ".part.tag" does not END with ".part", so the old endsWith() exclusion would have handed
// the player a few bytes of ETag to decode as a video.
java.io.File(dir, "sid.bin.part").writeBytes(ByteArray(10))
java.io.File(dir, "sid.bin.part.tag").writeText("\"v1\"")
assertNull("neither temp may be served as content", cache.getCachedFile("sid"))
}
@Test fun `Content-Range parsing rejects anything it cannot verify a total from`() {
assertEquals(400L to 1000L, ContentCache.parseContentRange("bytes 400-999/1000"))
assertNull("an unknown total gives nothing to check completeness against",
ContentCache.parseContentRange("bytes 400-999/*"))
assertNull(ContentCache.parseContentRange("items 0-1/2"))
assertNull(ContentCache.parseContentRange(null))
}
}

View file

@ -0,0 +1,98 @@
package com.remotedisplay.player.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* A customer assigned a different playlist to a screen and the screen kept showing the old content.
* Then they selected "no playlist" still the old content. Restarting the app showed the new
* content instantly, which ruled out downloads, the network and the server payload.
*
* Two faults met. #157's deferral holds a playlist change until the current item finishes its turn,
* and the item on screen was a YouTube video, which never finished: nothing armed an advance for it,
* so the pending change waited for an event that could not arrive. And "no playlist" went down the
* same deferral path, so the one action that should always take effect immediately did not.
*
* Invariants pinned here:
* - an empty new list is applied at once, never deferred
* - a real rotation still defers, because #157's reason for existing has not changed
* - an item that ends on a timer is recognised as such, YouTube included
*/
class PendingSwapTest {
private val LIVE = "content-on-screen"
private fun defer(
newIds: List<String>,
current: String? = LIVE,
isRunning: Boolean = true,
wallFollower: Boolean = false,
hasContent: Boolean = true,
) = PendingSwap.shouldDefer(isRunning, wallFollower, hasContent, current, newIds)
@Test fun THE_BUG_selecting_no_playlist_must_not_be_deferred() {
// The decisive observation from the report: "I selected No playlist ... it still showed the
// same video." An empty list is an operator saying stop, not an item rotating out.
assertFalse(defer(newIds = emptyList()))
}
@Test fun a_genuine_rotation_still_defers_157_must_not_regress() {
// The current item is gone from the new list but other items remain: let it finish.
assertTrue(defer(newIds = listOf("other-a", "other-b")))
}
@Test fun a_playlist_that_still_contains_the_live_item_never_defers() {
assertFalse(defer(newIds = listOf(LIVE, "other-a")))
}
@Test fun nothing_on_screen_yet_means_apply_immediately() {
// A first load has nothing to protect, so there is nothing to wait for.
assertFalse(defer(newIds = listOf("other-a"), hasContent = false))
assertFalse(defer(newIds = listOf("other-a"), current = null))
}
@Test fun a_stopped_controller_does_not_defer() {
// Otherwise a swap is parked on an instance that will never advance again.
assertFalse(defer(newIds = listOf("other-a"), isRunning = false))
}
@Test fun a_wall_follower_does_not_defer_it_obeys_the_leader() {
assertFalse(defer(newIds = listOf("other-a"), wallFollower = true))
}
@Test fun the_deferral_deadline_is_long_enough_for_a_normal_item_and_short_enough_to_notice() {
// The deadline is the backstop for "no advance ever arrives". It must clear a typical dwell
// comfortably (or it would cut ordinary items short) while still resolving fast enough that
// an operator watching the screen sees their change land.
val deadline = PendingSwap.DEADLINE_MS
assertTrue("deadline must exceed a common 30s dwell", deadline > 30_000L)
assertTrue("an operator should not wait minutes", deadline <= 120_000L)
}
}
/**
* The other half of the same report. A YouTube item ended on nothing: no timer was armed for it and
* a WebView embed reports no completion, so it held the screen forever and stranded whatever
* playlist change was waiting behind it.
*/
class ItemTimingTest {
@Test fun THE_BUG_a_youtube_item_must_end_on_a_timer() {
// Nothing else can end it — a WebView embed fires no completion event.
assertTrue(ItemTiming.endsOnTimer("video/youtube", isWidget = false))
}
@Test fun images_and_widgets_are_timed_as_they_always_were() {
assertTrue(ItemTiming.endsOnTimer("image/jpeg", isWidget = false))
assertTrue(ItemTiming.endsOnTimer("image/png", isWidget = false))
assertTrue(ItemTiming.endsOnTimer("text/html", isWidget = true))
}
@Test fun real_video_must_NOT_be_timed_or_clips_get_cut_short() {
// These end on STATE_ENDED. Arming a timer would truncate a clip at its configured duration,
// which is the regression to avoid while fixing the YouTube case.
assertFalse(ItemTiming.endsOnTimer("video/mp4", isWidget = false))
assertFalse(ItemTiming.endsOnTimer("video/webm", isWidget = false))
}
}

View file

@ -0,0 +1,63 @@
package com.remotedisplay.player.player
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* #234 "Android player dont play playlist properly" with two items only one ever played.
*
* PlaylistController is rebuilt with every MainActivity instance, so a relaunch handed it an empty
* list and then a full one ("0 -> N items") and it started from the top. On a panel that relaunches
* itself at each item boundary, the second item was preempted after ~135ms every single time
* reproduced on an Android 9 emulator, and matching prod play_logs where the second item recorded
* 0-1s durations while the first accumulated all the playtime. The reporter had never once seen it.
*
* Starting at the top is right for a genuinely cold start and wrong for a reload seconds later.
*/
class PlaybackResumeTest {
private val NOW = 1_000_000L
private val W = PlaybackResume.RESUME_WINDOW_MS
@Test fun THE_BUG_a_reload_moments_after_playing_continues_where_it_was() {
assertEquals(1, PlaybackResume.resumeIndex(
savedIndex = 1, savedAtMs = NOW - 5_000, nowMs = NOW, itemCount = 2))
}
@Test fun a_genuine_cold_start_still_begins_at_the_top() {
// Nothing saved: unchanged behaviour, which is what makes this safe to ship.
assertEquals(0, PlaybackResume.resumeIndex(-1, 0L, NOW, 3))
}
@Test fun a_stale_save_is_ignored_it_is_a_cold_start_not_a_continuation() {
assertEquals(0, PlaybackResume.resumeIndex(2, NOW - (W + 1), NOW, 3))
}
@Test fun just_inside_the_window_still_resumes() {
assertEquals(2, PlaybackResume.resumeIndex(2, NOW - (W - 1), NOW, 3))
}
@Test fun an_index_past_the_end_falls_back_rather_than_selecting_nothing() {
// The playlist shrank while we were away.
assertEquals(0, PlaybackResume.resumeIndex(7, NOW - 1_000, NOW, 3))
assertEquals(0, PlaybackResume.resumeIndex(3, NOW - 1_000, NOW, 3))
}
@Test fun an_empty_playlist_never_resumes() {
assertEquals(0, PlaybackResume.resumeIndex(1, NOW - 1_000, NOW, 0))
}
@Test fun a_clock_that_jumped_backwards_is_treated_as_stale_not_as_fresh() {
// Signage panels do correct their clocks. A negative age must not read as "0ms ago".
assertEquals(0, PlaybackResume.resumeIndex(1, NOW + 60_000, NOW, 2))
}
@Test fun a_zero_timestamp_is_not_1970_it_is_no_save_at_all() {
assertEquals(0, PlaybackResume.resumeIndex(1, 0L, NOW, 2))
}
@Test fun resuming_at_index_0_is_indistinguishable_from_starting_fresh() {
// Deliberate: index 0 needs no special handling, and the caller treats >0 as "resume".
assertEquals(0, PlaybackResume.resumeIndex(0, NOW - 1_000, NOW, 2))
}
}

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)))
}
}

View file

@ -0,0 +1,59 @@
package com.remotedisplay.player.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* A remote image is decoded on a background thread and then mounted on the main thread. It was
* mounted unconditionally, with no check that it was still wanted.
*
* ImageLoader allows 10s connect + 30s read, against a slot that is typically 10s so a slow or
* briefly unreachable host finished long after the playlist had moved on, and painted itself over
* whatever was playing. If that was a video the mount also called exoPlayer.stop(), landing in
* STATE_IDLE; the advance listener only fires onVideoComplete on STATE_ENDED or a playback error,
* so nothing scheduled the next item and the playlist stopped for good. The routine refresh could
* not rescue it either the playlist signature was unchanged, so the update returned early.
*
* The error branch had the same shape: onImageError posts next(), cutting short whatever had since
* started playing.
*
* PipOverlay.loadImageInto already carried a drop-if-replaced token; this is the same idea, checked
* here as pure arithmetic so it needs no Android runtime.
*/
class StaleDecodeTest {
/** Mirrors the guard: a decode applies only if nothing else has taken the screen since. */
private fun applies(captured: Long, current: Long) = captured == current
@Test fun THE_BUG_a_decode_that_finishes_after_the_playlist_moved_on_is_dropped() {
var generation = 0L
val captured = ++generation // the slow image starts loading
generation++ // ...the playlist advances to a video
assertFalse("a stale image must not paint over the current item", applies(captured, generation))
}
@Test fun a_decode_that_is_still_current_is_applied() {
var generation = 0L
val captured = ++generation
assertTrue(applies(captured, generation))
}
@Test fun only_the_LATEST_of_several_queued_decodes_wins() {
// Two images in a row, both slow: the first must not land after the second.
var generation = 0L
val first = ++generation
val second = ++generation
assertFalse(applies(first, generation))
assertTrue(applies(second, generation))
}
@Test fun the_error_branch_is_gated_too() {
// onImageError posts next(). Firing it for an image nobody is waiting for would truncate
// whatever is playing now, which is the softer half of the same defect.
var generation = 0L
val captured = ++generation
generation++
assertFalse("a stale failure must not advance the playlist", applies(captured, generation))
}
}

View file

@ -0,0 +1,64 @@
package com.remotedisplay.player.service
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* #234 follow-up: a playlist refresh emits a full device:register, and PlaylistController.next()
* asked for one on EVERY item advance. On a 10-second image that is six full re-registrations a
* minute, per device, forever each running 7+ server statements and the identity path, and each
* replying with a full playlist push. Measured on the reproduction: 9 item plays, 9 registrations.
*
* The heartbeat already pulls a fresh playlist every 60s, so the per-item call was duplicating a
* refresh that happens anyway.
*/
class RefreshThrottleTest {
private val NOW = 5_000_000L
private val MIN = RefreshThrottle.MIN_INTERVAL_MS
@Test fun the_very_first_refresh_always_goes_through() {
// A device that has never asked must not be held back by an empty timestamp.
assertTrue(RefreshThrottle.shouldRefresh(lastAtMs = 0L, nowMs = NOW))
}
@Test fun THE_BUG_a_second_refresh_moments_later_is_suppressed() {
// Two item advances a few seconds apart: the second must not re-register.
assertFalse(RefreshThrottle.shouldRefresh(NOW - 3_000, NOW))
assertFalse(RefreshThrottle.shouldRefresh(NOW - 10_000, NOW))
}
@Test fun once_the_interval_has_passed_it_refreshes_again() {
assertTrue(RefreshThrottle.shouldRefresh(NOW - MIN, NOW))
assertTrue(RefreshThrottle.shouldRefresh(NOW - (MIN + 1), NOW))
}
@Test fun just_under_the_interval_is_still_suppressed() {
assertFalse(RefreshThrottle.shouldRefresh(NOW - (MIN - 1), NOW))
}
@Test fun it_sits_under_the_heartbeat_pull_so_the_two_interleave() {
// The heartbeat refreshes every 60s. A window at or above that would systematically
// suppress the heartbeat's own pull, which is the one we are relying on to remain.
assertTrue(MIN < 60_000L)
}
@Test fun a_backwards_clock_never_wedges_refreshing() {
// Signage panels correct their clocks. A future 'last' must not disable refresh until the
// clock catches up — that would strand a device on a stale playlist for hours.
assertTrue(RefreshThrottle.shouldRefresh(NOW + 3_600_000, NOW))
}
@Test fun a_ten_second_item_collapses_from_six_refreshes_a_minute_to_about_one() {
// Walk a minute of 10s items and count what actually gets through.
var last = 0L
var allowed = 0
var t = NOW
repeat(6) {
if (RefreshThrottle.shouldRefresh(last, t)) { allowed++; last = t }
t += 10_000
}
assertTrue("expected roughly one refresh per minute, got $allowed", allowed <= 2)
}
}

View file

@ -0,0 +1,44 @@
package com.remotedisplay.player.service
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* onUnpaired was assigned TWICE in setupServiceCallbacks. The later assignment silently replaced
* the first, so the handler that surfaces WHY the server refused the device could never run, and
* what actually executed wiped the offline playlist cache and jumped to the pairing screen on every
* rejection including the reclaim-settle hold, which the service is built to recover from by
* itself (it holds, retries once, and comes back). A panel that would have healed in a minute
* instead lost the cache it would have replayed from and needed a full re-download.
*
* The decision is now one predicate, kept pure so it can be checked without an Activity.
*/
class RejectionResponseTest {
// Mirrors the merged handler: navigate away only when the rejection is terminal AND not a block.
private fun goesToProvisioning(transient: Boolean, blocked: Boolean) = !transient && !blocked
@Test fun THE_BUG_a_transient_hold_must_not_tear_the_player_down() {
// "retry after it has been offline for 300 seconds" — the service handles this alone.
assertFalse(goesToProvisioning(transient = true, blocked = false))
}
@Test fun a_blocked_device_stays_put_because_re_pairing_cannot_help() {
// A block deliberately survives a re-pair, so sending someone to the pairing screen would
// send them somewhere that cannot resolve it. Show the reason instead.
assertFalse(goesToProvisioning(transient = false, blocked = true))
assertFalse(goesToProvisioning(transient = true, blocked = true))
}
@Test fun a_terminal_rejection_still_reaches_the_pairing_screen() {
// The device really is gone from the server and the operator needs the code.
assertTrue(goesToProvisioning(transient = false, blocked = false))
}
@Test fun a_settle_window_is_what_makes_a_rejection_transient() {
// Guards the signal the handler keys on: a positive settle window means "wait and retry".
assertTrue(0 < 300)
assertFalse(0 > 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

@ -1,51 +1,469 @@
# ScreenTinker on BrightSign — capability probe
# ScreenTinker on BrightSign
Not a port. This answers, on **real hardware**, the questions that decide what a port looks like —
so the design isn't guessed from documentation.
The player is the ordinary web player (`server/player/index.html`) running in an `roHtmlWidget`.
It already runs unmodified on real hardware — a Series 5 (HD1026, BOS 9.1, Chromium 120) played
4,723 items over 12.4h averaging 9.4s against a 10s slot. So the port is not "can it run". It is
the four things a page cannot do for itself.
## Run it
```
autorun.brs the host: owns the widget, identity, outputs, recovery
| @brightsign/messageport (bidirectional)
st-bridge.js the page's half of the same contract
|
server/player/index.html the unmodified player
```
1. FAT32-format an SD card. It must be **empty** — a card with leftover data won't trigger a fresh
provisioning cycle.
2. Copy `autorun.brs` and `probe.html` to the **root**.
3. Insert with the player powered off, then power on.
4. Read the screen. Remote devtools are on `http://<player-ip>:2999` if you'd rather read it there.
5. **Power-cycle and reload.** The reboot markers are the point — first run writes them, second run
says which survived.
## Files
## What it answers, and why each matters
| check | why it decides something |
| file | role |
|---|---|
| which `@brightsign/*` modules resolve | `nodejs_enabled: true` injects them into the runtime. If injection is origin-independent, a **remotely-served** page gets them too — which is the whole cheap path. |
| `registry` survives reboot | ScreenTinker's device identity (`deviceId`, `deviceToken`, `paired`, `serverUrl`) lives in `localStorage`, and on BrightSign that behaves like sessionStorage. Without a durable store every panel re-pairs on every boot and spawns a new device row. |
| `localStorage` survives reboot | If it does on this OS build, the port gets dramatically simpler. Reports say it doesn't; worth confirming rather than inheriting a 2019 answer. |
| serviceWorker / Cache API / indexedDB | The web player registers `/player/sw.js` for content caching. If unavailable, offline playback has to move to BrightSign's storage APIs — which is the "extra mile" work anyway. |
| `<video>` + h264 | Whether HTML5 video is viable as a stopgap before wiring the native decode path. |
| CSS `clamp()` | The directory-search keyboard scales with `clamp(…vh…)`. Chromium 87 (Series 4) is the risk. |
| reach `screentinker.com/api/status` | Rules network/TLS out before blaming anything else. |
| `autorun.brs` | BrightScript host. Builds the widget, supervises it, persists identity, drives a second output, executes what the page cannot. |
| `st-bridge.js` | Loaded by the player on this platform. Registry identity, restart-instead-of-reload, heartbeat, sync-backend reporting. Degrades to no-ops everywhere else, so it is safe to load unconditionally. |
| `st-sync.js` | Native SyncManager adapter. Inert without the platform module, so the player falls back to its own group sync. |
| `probe.html` | The original capability probe. Still useful on a new model/OS build. |
| `offline.html` | Local fallback page — names the server, keeps probing it, and asks the host to restart the player the moment it answers. |
## Then: the actual question
## The four things the host exists for
The probe runs **locally** first to establish the baseline. Once `registry` resolves from
`file:///`, change `url:` in `autorun.brs` to a hosted copy of `probe.html` and re-run.
**1. It owns the widget lifecycle.** A page-initiated `location.reload()` does not reliably bring
an `roHtmlWidget` back. On 2026-07-28 a ScreenTinker deploy reloaded every connected player;
the BrightSign was the only one that never returned, and a browser on the same deploy reloaded and
was heartbeating minutes later. So the page never reloads itself here — it posts
`{type:"restart"}` and the host tears the widget down and builds a new one. Without this, every
deploy silently darkens every BrightSign panel until someone power-cycles it.
- **Still resolves →** point the widget at the hosted player, swap identity persistence to the
registry, done. Days, not weeks.
- **Doesn't resolve →** a local shim page owns the registry and passes identity to the hosted
player in an iframe via `postMessage`. The Chromium 110/120 notes say iframes now *require*
`postMessage()` for BrightSign objects, which suggests this is the sanctioned pattern rather
than a workaround.
**2. It recovers.** `load-error` retries with backoff (5s → 15s → 30s → 60s) and after three
failures falls back to a local page, so a dead server shows something truthful instead of white.
On top of that, a watchdog: the page beats every 30s and three missed beats rebuild the widget.
That covers the case `load-error` never reports — a page that loaded fine and then wedged on a
dead socket, a JS exception, or a stalled decoder.
**3. Identity lives in the registry.** `localStorage` is tied to the page's origin and quota; the
registry survives reboots, content updates and origin changes. The hardware serial is the stable
id, so two panels imaged from the same card never collide — which is exactly how the web player's
hardware-only fingerprint once merged two identical panels into a single device row.
**4. It reaches BrightScript-only capabilities** — video mode, a second output, and native
BrightWall sync — on the page's behalf, over `@brightsign/messageport`.
## Where the files go — card OR internal flash
```
autorun.brs the host
offline.html local fallback, used after three failed loads
screentinker.json optional — server URL, sync backend, output mode
```
**A player will boot `autorun.brs` from internal flash, not just from a card.** Confirmed on real
hardware (XT245, BOS 9.0.189) whose microSD interface is physically dead:
```
Loading 'FLASH:/autorun.brs'
BSPLAY: https://screentinker.com/player?platform=brightsign&serial=…&model=XT245
```
That matters far beyond one broken unit — it means a player with no card, or a failed card slot,
is still fully deployable. Push the files over SFTP to `/storage/flash` (user `brightsign`, blank
password, once SSH is enabled) and reboot.
`StorageRoot()` in `autorun.brs` therefore refuses to assume: it probes for `FLASH:/autorun.brs`
and falls back to `SD:`. Hard-coding `SD:` is exactly the bug that made the first flash boot fail —
the script loaded and then could not find its own `index.html`.
**`st-bridge.js` and `st-sync.js` do NOT go on the card.** The player pulls them from the server
(`/player/st-bridge.js`, `/player/st-sync.js`) so they can never skew from the player that uses
them. A stale copy on a card is precisely the version skew that would leave a panel unable to
restart itself.
## autorun.zip — one file instead of four
`scripts/build-autorun-zip.sh` packages the host, the fallback page and the config into a single
`autorun.zip`, attached to every GitHub release:
```bash
scripts/build-autorun-zip.sh --server https://your-server
```
Drop it on the root of a player's storage and power-cycle. `autozip.brs` unpacks it in place,
renames it `autorun.zip.done` so it never re-extracts, and reboots into the player.
Two rules the format imposes, both of which fail silently if broken:
- **The archive must expand to files at its ROOT**, with no wrapper directory — a player extracts
to the storage root, so a nested folder puts `autorun.brs` somewhere the player never looks and
the card appears to do nothing. The build script zips from *inside* the staging directory and
then asserts the layout rather than trusting it.
- **`autorun.brs` must NOT sit next to `autorun.zip`** on the storage root; its presence stops the
zip being processed at all. It belongs inside the archive.
The rename is what makes it idempotent. Without it the player extracts, reboots, extracts, reboots
— a loop that looks exactly like a hardware fault. An extraction *failure* deliberately does not
rename, so a truncated copy is retried after someone replaces it rather than skipped forever.
Requires BrightSignOS 7.0.60+ (`roUnzip`).
## Provisioning
Config resolves `screentinker.json` on the card **>** registry **>** built-in default. The JSON
file is how a batch gets imaged without touching each box:
```json
{ "server_url": "https://screentinker.com", "sync_backend": "auto", "output_mode": "single" }
```
## Dual output
`output_mode` is `single` | `dual` | `clone`.
- **dual** — a second widget loads the same player with `&screen=2`, so the server can hand it its
own playlist. Two independent displays from one player.
- **clone** — the second widget loads `&screen=1`: the same content on both outputs.
Confirmed multi-output: **XC2055** (dual HDMI) and **XC4055** (quad).
⚠️ **Do not trust the series-level spec blurb.** It credits the whole XT5 family — XT245, XT1145,
XT2145 — with "dual HDMI outputs", but an **XT245 in hand is single-output**; that phrase appears
to cover HDMI *in* plus *out*. Verify the individual model before enabling `dual`.
Every other model is single-output, so the second widget is only ever created when the config asks
for it — an unsupported model keeps working as a normal single-screen player rather than failing to
start.
## Synchronisation — ours or theirs
Both, chosen per group. `server/lib/sync-backend.js` decides and `resolveSyncBackend()` is pure,
so the decision is tested without a fleet (`server/test/sync-backend.test.js`).
| backend | reach | accuracy |
|---|---|---|
| `screentinker` | Android, web, Tizen, BrightSign — any mix | to the second; clock-derived, no leader, survives a server outage |
| `brightsign` | BrightSign only | frame-accurate (BrightWall) |
`auto` picks native sync when **every** member is a BrightSign and ours otherwise. Explicit
settings are honoured, with one refusal: native sync selected for a group containing a
non-BrightSign display **downgrades and reports why**. A group that half-syncs is worse than one
that syncs to the second everywhere — and the failure would be invisible from the dashboard,
because the BrightSigns would look perfectly synchronised while the odd panel drifted alone.
A player paired before this port is still recognised, by its BrightSign user agent.
### How the choice reaches a screen
`device_groups.sync_backend` (`auto` | `screentinker` | `brightsign`) is the operator's **request**.
The server resolves it per push through `resolveSyncBackend()` and sends the answer — plus the
reason and a `downgraded` flag — in the `group_sync` payload, so the players, the dashboard and the
stored setting can never disagree about which protocol is running.
Three things force a fallback to our protocol, and each is reported rather than applied silently:
| condition | why native sync cannot run |
|---|---|
| any non-BrightSign member | BrightWall cannot include a foreign screen |
| members on different subnets | it is multicast; it does not cross networks |
| the elected leader is offline | it is leader/follower — nobody would broadcast |
That last one has no equivalent in our protocol, which is leaderless and carries on regardless.
Leadership uses the existing election (`resolveGroupLeader`): the pinned leader if it is an online
member on the shared playlist, else the first online member, else the first member by id.
**Item selection stays clock-derived under both backends.** Native sync only replaces the
seek/nudge drift correction, because `setSyncParams` has the video element hold its own alignment —
and correcting it ourselves would fight the platform. That also keeps images and widgets, which have
no `setSyncParams`, advancing with the videos instead of drifting off on their own.
## Command parity
The web player handles four of the ~20 fleet commands — `launch`, `refresh`, `screen_on`,
`screen_off` — because a browser tab genuinely cannot do more. A BrightSign can, through the host
and the platform APIs:
| command | web player | BrightSign |
|---|---|---|
| `screen_on` / `screen_off` | black overlay; panel stays lit | **CEC** Image View On / Standby — the display actually sleeps |
| `reboot` | ignored | **real reboot** via `RebootSystem` in the host |
| `set_volume` | — | applied to current and future media |
| `refresh` | `location.reload()` | widget rebuilt by the host (reload is unreliable here) |
### ⚠️ Nothing in the DOM can cover video
With `hwz_default: "on"` the widget decodes video onto a **hardware plane**, and the graphics plane
— everything in the DOM — sits behind it. Blanking the screen took three attempts on real hardware,
and each failure taught the same lesson from a different angle:
1. **Black overlay** → the video played straight *through* it. A `z-index: 9999` div cannot cover a
hardware plane.
2. **Pause + hide the element** → playback stopped, but the **last decoded frame stayed on screen**.
Hiding a DOM element does nothing to the plane; the plane is not part of the DOM.
3. **Pause + `removeAttribute('src')` + `load()`** → releases the plane. Black at last.
Coming back out re-mounts through `nextItem()`, because a torn-down element cannot be resurrected.
The playlist keeps advancing while the screen is off, so each newly started item is torn down too,
caught on the `play` event in the capture phase — otherwise the next video lights the panel back up.
Any feature that assumes an overlay can hide video needs rethinking here: screen blanking, masking,
fades over video.
`displayPower()` (CEC) is best effort and deliberately **not** load-bearing — it returns false when
CEC is unavailable and the media teardown does the real work. Our XT245 reports
`failed to get cec clock` in the kernel log and does not respond to CEC at all, which is exactly why
blanking must not depend on it. Plenty of displays ignore broadcast CEC or need direct addressing. Volume is re-applied on every `play` event in the capture phase, because
media elements are created per item across several code paths and setting it once would otherwise
last only until the playlist advanced.
Still Android-only, and correctly inert here: the Tier-2 device-owner commands (`kiosk_lock`,
`install_apk`, `shell`, `block_uninstall`, …) and `set_brightness` / `set_screen_timeout`, which
have no BrightSign equivalent — a signage player has no per-window brightness or screen timeout.
## Declared capabilities
The table above says what a BrightSign *can* do. What the dashboard actually offers comes from
`BS.capabilities()`, computed fresh on every call and sent with the device registration, where
`server/lib/player-capabilities.js` turns it into rendered controls.
It is computed rather than tabulated because **the same model differs from unit to unit**. Our
XT245 supports remote screenshots with an SSD fitted and not without — the DWS snapshot endpoint
writes the full-size capture to disk before returning a thumbnail, so a unit booting from internal
flash is answered `No primary storage found`. No static per-platform table can know that, and a
table that guessed would put a button in the dashboard that cannot work.
### How each one is decided
| capability | condition | why |
|---|---|---|
| `playback.video` `.image` `.widget` `.youtube` `.zones` | always | properties of the renderer, not the hardware |
| `audio.mute` `audio.volume` | always | media-element level, re-applied per `play` |
| `sync.clock` | always | pure JS, needs no host |
| `remote.input` | always | synthesised DOM events; needs no `mouse_enabled` |
| `playback.transitions` `playback.pip` | always, **with a caveat** | see below |
| `offline.cache` | `navigator.serviceWorker` exists | no SW, no offline story |
| `system.restart_player` `system.reboot` `display.rotation` `display.resolution` | host bridge is live | each is a BrightScript call |
| `remote.screenshot` `remote.stream` `system.self_update` | host reports a mounted volume | DWS needs primary storage; the updater needs somewhere to stage `autorun.zip` |
| `display.power` | `@brightsign/cec` resolves | weak signal — see below |
| `sync.native` | `@brightsign/syncmanager` **and** OS ≥ 8.2.10 | below the floor the module can exist and silently do nothing |
The storage answer comes from a `probe` message the bridge posts to the host during boot, before
the player registers. `StorageProbe()` in `autorun.brs` walks `SSD:`, `SD:` and `USB1:` through
`roStorageHotplug.GetStorageStatus().mounted` and reads real capacity via `roStorageInfo`. There is
no JS equivalent for either, which is also why device telemetry now reports the **disk** rather than
the widget's cache quota — the previous numbers were the `storage_quota` from `autorun.brs`
presented as if they were the drive.
`FLASH:` is deliberately excluded from that walk. Internal flash is where the player boots from, not
a volume the DWS will accept a snapshot on; counting it would re-introduce exactly the button that
does nothing.
**Unknown is treated as NO.** If the probe never answers — a widget built without `nodejs_enabled`
has no host at all — nothing storage-gated is declared. A control that appears later, once a disk is
fitted and the player reconnects, is a much smaller problem than one that silently fails today.
### Never declared
| | |
|---|---|
| `system.kiosk` | no lock-task or device-owner concept. The player is the only application on the box, so kiosk is not a mode to enter — it is the permanent state |
| `system.brightness` | no per-window or system brightness control |
| `system.screen_timeout` | no OS screen timeout; blanking is scheduled content, not a setting |
| `system.install_apk` | not Android |
| `system.shell` | no remote shell exposed to the player |
| `system.time` | BrightScript **can** set time and timezone — this host does not implement it. Declaring an unimplemented capability is the same lie in the other direction |
Only the last one is a gap rather than a platform limit. The other five have no BrightSign
equivalent and should stay undeclared permanently.
### The two caveated declarations
**`playback.transitions` / `playback.pip`** both composite DOM content over video, and with `hwz`
the video is on a hardware plane the DOM sits *behind* (see above). They work over images and
widgets and may be invisible over video. Declared anyway: the failure is benign — a transition
degrades to a hard cut, which the engine already does on any failure — and withholding them would
remove a feature that genuinely works for the non-video majority of content.
The likely fix is `roVideoMode.SetGraphicsZOrder("front")`, **deliberately not applied**. Changing
the z-order blind risks hiding video entirely on a player that currently works, and the trade is not
obvious: putting graphics in front may mean video is only visible through a colour key. This wants a
hardware experiment on a unit that is not in service — set the z-order in `autorun.brs` before
`FullScreenRect()`, play a video, and check that (a) video is still visible and (b) a DOM overlay
now covers it. Until someone runs it, the honest state is "transitions work except over video".
**`display.power`** is declared on module presence, which we know is a weak signal: our XT245
resolves `@brightsign/cec` perfectly while the kernel logs `failed to get cec clock` and the display
never responds. There is no way to distinguish "sent" from "received" without a cooperating display.
Blanking does not depend on it — the player tears the media down, which is what actually works — so
a display that ignores CEC still goes dark. The capability being optimistic here costs an
already-working feature nothing.
### Needs hardware to verify
Everything below was implemented against the documented APIs and the dev-cookbook, and reasoned
through, but has not run on a unit in the state that exercises it:
- **The storage probe returning `present: true`.** Our XT245 has a dead microSD interface and boots
from flash, so it has only ever been observed answering `false`. The false path is verified on
hardware; the true path is verified only in tests.
- **`remote.screenshot` / `remote.stream` end to end** with a disk fitted — the DWS snapshot call
has never succeeded on our unit for that reason.
- **`system.self_update`** staging `autorun.zip` onto a real volume.
- **`sync.native`** on two or more units on one L2 network. Requires `networking/ptp_domain="0"`
and a reboot.
- **The `SetGraphicsZOrder` experiment** above.
## Offline playback
Content bytes are cached by the service worker (`server/player/sw.js`) into a dedicated
`rd-content-v1` cache, so a player that loses its server keeps playing its playlist.
This used to be left to the browser's HTTP cache — the server sends
`Cache-Control: public, max-age=2592000, immutable`. That is fine on a desktop and is **not a
documented-persistent store here**: BrightSign guarantees survival across reloads, app restarts and
reboots for **IndexedDB, localStorage and SQLite**, and their own answer for offline video is to
cache the bytes explicitly. A panel could come back from a power cut with its playlist intact (that
lives in `localStorage`) and no media to play.
The reason content was skipped originally is real, and `server/lib/player-cache-policy.js` is what
makes intercepting it safe. Video elements issue **range requests** when they seek, and naive
caching breaks playback in two ways that are worse than not caching at all:
- storing a `206` as if it were the whole file — every later full request gets a fragment, and it
stays broken until eviction
- answering a range request with a `200` — some media stacks treat the mismatch as fatal and the
video never starts
So only complete `200`s are ever stored, and a range request is served by slicing the stored body
into a correct `206`. The content cache is deliberately **not** dropped when the shell is
re-versioned, or every deploy would re-download the whole playlist over a link that may be exactly
what is broken.
## Self-update
The player can replace its own host package. This is the most dangerous thing it does: a truncated
or half-applied `autorun.brs` is a dark panel and a site visit, because there is no app underneath.
The safety is the **ordering**, and every step earns its place:
1. Download to `autorun.zip.part` — never straight to `autorun.zip`. A file still downloading must
never be a candidate for extraction.
2. Verify **sha256 and size** before promoting. A captive portal answering with a login page
produces a perfectly well-formed small file; the size floor catches that, the hash catches the
rest. sha256 specifically, because that is what BrightScript's `roMessageDigest` can compute —
a checksum the player cannot verify is an unverifiable package.
3. Promote: delete the `.done` marker **first**, then rename `.part``autorun.zip`, then reboot.
Marker first is not stylistic — leaving it makes the next boot skip the new archive and the
update silently never happens.
4. A failed extract renames the archive to `.bad` rather than retrying. A zip that cannot be
unpacked will not unpack on the tenth attempt, and retrying every boot is a loop that looks
exactly like a hardware fault.
**The decision is the server's**, in `server/lib/brightsign-update.js` — unit-tested, and the same
place the prerelease rule lives. The host only executes what it is told; re-implementing the version
comparison in BrightScript would put the prerelease trap somewhere it cannot be tested.
**The version is baked into `autorun.brs`**, stamped at build time by both
`scripts/build-autorun-zip.sh` and `server/lib/brightsign-package.js`, anchored on the
`ST_PACKAGE_VERSION` marker. A version record that can disagree with the code actually running is
the OTA-loop condition by the back door: apply, still report the old version, get offered the same
package forever.
**The manifest and the download come from one buffer**, hashed once. Advertising a version whose
checksum does not match the bytes served is the same loop from the front door.
Config: `self_update` (default **on** — a fleet that cannot be updated remotely needs a van) and
`allow_prerelease` (default off, mirroring the Android beta channel; an opted-in player also
*holds* a prerelease of its own core rather than being pulled back to the release).
## Rotation
Rotate the OUTPUT, never the DOM. The web player rotates with a CSS transform — correct in a
browser, wrong here: with `hwz` enabled the video decodes onto a hardware plane the DOM cannot
transform, so a CSS rotation turns the images and widgets and leaves the video sideways on a
portrait panel.
`roVideoMode` takes a transform (`normal` / `90` / `180` / `270`) and rotating the screen rotates
**every layer**, video included, because it happens below the compositor. The player asks the host
first; when the host succeeds it clears its own CSS transform, or the graphics would rotate twice.
If the host cannot, the CSS path stands — rotating most of the content beats rotating none.
Tizen reached the same conclusion independently and routes portrait video through AVPlay, with the
comment that a CSS-rotated `<video>` "blacks out". Any platform that composites video below the DOM
needs its rotation done at the output, and this is the second one we have found.
## What is NOT done yet
Stated plainly so nobody reads this as finished:
- **Nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields** the player reports.
Temperature telemetry likewise has no schema to land in yet. Storage does now report the real
drive (via the capability probe) rather than the widget's cache quota.
- **Native sync is wired but UNPROVEN on hardware.** The player drives it end to end — the leader
announces on each advance, every member (leader included) binds via `attachVideo()` on a new id,
and the resolved backend is chosen per group and pushed down. It cannot be verified with one
player: a single unit is trivially "in sync with itself". **Two BrightSigns on one subnet are
needed** to confirm frame alignment, that the leader does not run ahead, and that the 1Hz repeat
causes no visible reload.
```js
const SyncManager = require('@brightsign/syncmanager'); // BrightSignOS 8.2.10+
const sync = new SyncManager('', 'ScreenTinkerSync', '224.0.126.10', 1539);
sync.leader = true; // followers just omit this
sync.addEventListener('syncevent', (e) => { // BOTH roles listen
if (e.id === lastId) return; // 1Hz rebroadcast — dedupe!
lastId = e.id;
video.setSyncParams(e.domain, e.id, e.iso_timestamp); // extension on <video>
video.load(); video.play();
});
sync.synchronize('item_' + Date.now(), 1000); // leader only; msDelay to prep
```
Three properties that shaped the design: it is **leader/follower** where ours is leaderless (and
the leader starts from its OWN broadcast, or it runs ahead of the group); it synchronises
**video only**, so images and widgets get item-boundary alignment at best; and it is
**multicast**, so the whole group must share one L2 network — the resolver now treats differing
subnets as evidence against it.
Also: MP4/MOV are fine, MPEG-TS needs its presentation timestamp starting at 0, MPEG-PS is
unsupported. `synchronize()` rebroadcasts at 1Hz so late-powered players still join, which is
why the dedupe above is mandatory rather than an optimisation — without it every player reloads
its video once a second, forever.
- **Addressing a specific HDMI connector from JS is unverified.** `@brightsign/videooutput`
documents `setMode({width,height,refreshRate})` with no output index. Dual output above assumes
a second widget maps to the second connector; that needs hardware confirmation.
- **Registry from a remote origin is still unproven** — the original probe question. If injection
turns out to be origin-dependent, identity moves to a local shim page that owns the registry and
passes it to the hosted player in an iframe via `postMessage`.
- **Written against the docs first, then corrected by hardware.** The port was checked
line-by-line against the `brightsign/dev-cookbook` examples, which corrected four config keys,
the registry API and a hard SyncManager requirement (see below). It has since run on a real
XT245 booting `FLASH:/autorun.brs` — playback, identity, blanking, rotation and the storage
probe's *negative* answer are all confirmed there. What that one unit cannot exercise is listed
under "Needs hardware to verify" above: it has no working storage and there is only one of it.
## Verified against the dev-cookbook
`autorun.brs` and `st-bridge.js` were reviewed against the real examples rather than the prose:
- **`brightsign_js_objects_enabled: true` is required** alongside `nodejs_enabled` for
`require("@brightsign/*")` (`syncmanager-js/autorun.brs`). Without it the bridge degrades to
no-ops and the player silently loses identity *and* restart delegation — the failure would look
like "BrightSign just doesn't work" rather than a missing flag.
- **`storage_path` is a directory name** (`"/cache"`), not a volume, and **`storage_quota` is a
string** (`indexeddb-caching/autorun.brs`).
- **`security_params: { websecurity: true }`** and `hwz_default: "on"` are the shapes the examples
use; local URLs carry the volume (`file:/SD:/index.html`).
- **The registry API is asynchronous and section-oriented**: `read(section, key)` returns a
**Promise** and writes take an object — `write(section, {k: v})`. The bridge prefetches into a
cache and exposes `onReady()`; the player waits for it before its first connect, because
registering early would pair the panel as a new display and strand its real row.
- **SyncManager needs `networking/ptp_domain = "0"`, applied by a reboot**
(`syncmanager-js/autorun.brs`). Done only when this player is configured for native sync, and
read-before-write so it reboots at most once rather than every boot.
- Confirmed correct as written: `@brightsign/messageport` (`new`, `addEventListener('bsmessage')`,
`PostBSMessage`), the `roHtmlWidgetEvent` loop, and `RebootSystem()`.
- The notes state a widget URL may be **"an externally hosted page"** with the same access to the
BrightSign JS APIs, which is the answer the original probe was built to get — still worth
confirming on hardware, but the documented answer is the favourable one.
## Model notes
Target **Series 6** (ships Chromium 120) or **Series 5** (upgradeable via the `html/widget_type`
registry key). **Series 4 is pinned to Chromium 87** — a result from one would be misleadingly
pessimistic.
## Scope
A URL wrapper is the on-ramp, not the destination. Doing this properly on BrightSign means the
registry for identity, SD for offline media, and their native video path rather than `<video>`.
ScreenTinker's existing multi-zone layouts, video walls and group sync map onto that platform's
strengths unusually well — those are the parts worth showing off.
Target **Series 5** (Chromium 120) or newer. **Series 4 is pinned to Chromium 87**, and Series 4
and older have fixed graphics/JS memory splits (XTx43/44: 512MB/512MB; HDx23: 256MB/128MB) where
Series 5 allocates dynamically. Image size defaults to 2048x1280x32bpp (3840x2160 on XT/4K models)
and is raised with `roVideoMode.SetImageSizeThreshold()`.

File diff suppressed because it is too large Load diff

111
brightsign/autozip.brs Normal file
View file

@ -0,0 +1,111 @@
' ScreenTinker — autorun.zip unpacker.
'
' Ships INSIDE autorun.zip, at its root. The card (or internal flash) carries a single file —
' autorun.zip — and this script unpacks it in place, marks it done so it never re-extracts, and
' reboots into the real host.
'
' That is the whole point of the zip: one file to hand someone, or to drop on a hundred cards,
' instead of four files that must all arrive intact and in the right place. A partially-copied
' set of loose files boots into something broken; a partially-copied zip simply fails to extract
' and leaves the player where it was.
'
' ⚠️ autorun.brs must NOT sit next to autorun.zip on the storage root — its presence stops the zip
' 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.
'
' 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+.
' WHERE the archive is. A player may be fed from USB, a card, an SSD, or internal flash — and the
' unit that drove this port boots from FLASH because its card interface is physically dead. Probing
' 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:", "SD2:", "SSD:", "FLASH:"]
for each v in volumes
if FileExists(v + "/autorun.zip") then return v
end for
return ""
End Function
Sub Main()
root$ = SourceRoot()
if root$ = "" then
print "[st-autozip] no autorun.zip on any volume — nothing to do"
return
end if
zipPath$ = root$ + "/autorun.zip"
extractPath$ = root$ + "/"
donePath$ = root$ + "/autorun.zip.done"
print "[st-autozip] volume "; root$
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 FileExists(extractPath$ + "autorun.zip.done") then
print "[st-autozip] already unpacked (autorun.zip.done present) — leaving it alone"
return
end if
print "[st-autozip] unpacking "; zipPath$
package = CreateObject("roBrightPackage", zipPath$)
if package = invalid then
print "[st-autozip] ERROR: could not open the archive — is it STORED (no compression)?"
' Deliberately NOT marking it done: a corrupt, truncated or wrongly-compressed copy should
' be retried once someone replaces the file, not silently skipped forever.
return
end if
' 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"
' 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
print "[st-autozip] rebooting into the unpacked player"
sleep(2000)
RebootSystem()
End Sub
' 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

119
brightsign/offline.html Normal file
View file

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ScreenTinker — reconnecting</title>
<style>
/* Deliberately self-contained: this page is the thing that shows when the network is gone,
so it can never depend on a font, a stylesheet or an image it would have to fetch. */
html, body {
margin: 0; height: 100%;
background: #0d1117; color: #c9d1d9;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: center; justify-content: center;
}
.card { text-align: center; max-width: 70vw; }
h1 { font-size: 3.2vw; font-weight: 600; margin: 0 0 1.2vh; color: #e6edf3; }
p { font-size: 1.6vw; line-height: 1.5; margin: 0.6vh 0; color: #8b949e; }
.server { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #58a6ff; word-break: break-all; }
.dot {
display: inline-block; width: 0.9vw; height: 0.9vw; border-radius: 50%;
background: #f85149; margin-right: 0.6vw; vertical-align: middle;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.25 } }
.meta { margin-top: 3vh; font-size: 1.1vw; color: #6e7681; }
</style>
</head>
<body>
<div class="card">
<h1><span class="dot"></span>Can't reach the server</h1>
<p>This display is working. It cannot currently reach</p>
<p class="server" id="server">its ScreenTinker server</p>
<p id="status">Retrying…</p>
<p class="meta" id="meta"></p>
</div>
<script>
/*
* The local fallback page. autorun.brs loads this after three failed attempts at the real player,
* so the screen says something truthful instead of showing white until someone visits the site.
*
* Two jobs, and nothing else:
* 1. Say what is wrong, and name the server, so whoever walks past can act on it.
* 2. Keep testing, and hand control back the moment the server answers.
*
* It asks the HOST to restart the widget rather than navigating itself — same reason the player
* never calls location.reload() on this platform: an in-page navigation is not reliably a restart
* an roHtmlWidget comes back from.
*/
(function () {
'use strict';
function qs(name) {
var m = new RegExp('[?&]' + name + '=([^&]*)').exec(location.search || '');
return m ? decodeURIComponent(m[1]) : null;
}
var server = qs('server') || '';
var attempt = 0;
var startedAt = Date.now();
if (server) document.getElementById('server').textContent = server;
var port = null;
try {
if (typeof require === 'function') {
var MessagePortClass = require('@brightsign/messageport');
port = new MessagePortClass();
}
} catch (e) { port = null; }
function setStatus(text) { document.getElementById('status').textContent = text; }
function setMeta(text) { document.getElementById('meta').textContent = text; }
function minutesDown() {
var m = Math.floor((Date.now() - startedAt) / 60000);
return m < 1 ? 'less than a minute' : (m === 1 ? '1 minute' : m + ' minutes');
}
function probe() {
attempt++;
if (!server) { setStatus('No server configured on this card.'); return; }
setStatus('Retrying… (attempt ' + attempt + ')');
// cache-bust: a stale 200 from the widget cache would send us back to a server that is still
// down, and the host would bounce straight back here — a loop that looks like flickering.
var url = server.replace(/\/+$/, '') + '/api/status?probe=' + Date.now();
fetch(url, { cache: 'no-store' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
setStatus('Server is back — restarting the player…');
if (port && typeof port.PostBSMessage === 'function') {
port.PostBSMessage({ type: 'restart', reason: 'server reachable again' });
} else {
// No host bridge (widget without node integration). Navigating is second best, but
// doing nothing would strand the panel here forever.
location.href = server;
}
})
.catch(function (err) {
setMeta('Offline for ' + minutesDown() + ' · last error: ' + (err && err.message ? err.message : 'unreachable'));
schedule();
});
}
// Backoff, capped. A panel that has been down for hours must not hammer a server that is
// coming back up — every player on the site would hit it at once.
function schedule() {
var delay = attempt < 3 ? 5000 : (attempt < 10 ? 15000 : 60000);
setTimeout(probe, delay);
}
probe();
})();
</script>
</body>
</html>

View file

@ -0,0 +1,6 @@
{
"server_url": "https://screentinker.com",
"sync_backend": "auto",
"output_mode": "single",
"inspector": true
}

View file

@ -0,0 +1,34 @@
' ScreenTinker — storage self-test.
'
' NOT the player. This is the smallest known-good BrightScript that proves the player is reading
' the card at all, copied from the dev-cookbook html-starter example so the script itself is not
' a variable.
'
' If the screen shows the green panel: storage is fine, and any failure is in the real autorun.brs
' or downstream (network, server, widget config).
' If the screen still says "Please insert storage device": the player is not reading this medium,
' and no amount of work on the player code will help.
function main()
mp = CreateObject("roMessagePort")
vidmode = CreateObject("roVideoMode")
width = vidmode.GetResX()
height = vidmode.GetResY()
r = CreateObject("roRectangle", 0, 0, width, height)
config = {
nodejs_enabled: true
brightsign_js_objects_enabled: true
url: "file:/FLASH:/index.html"
port: mp
}
h = CreateObject("roHtmlWidget", r, config)
h.Show()
while true
msg = wait(0, mp)
print "msg received - type=";type(msg)
end while
end function

View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>ScreenTinker storage self-test</title>
<style>
html,body{margin:0;height:100%;background:#0b3d1f;color:#eaffea;
font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;text-align:center}
h1{font-size:5vw;margin:0 0 2vh} p{font-size:2vw;margin:.5vh 0;color:#b9e8c4}
code{font-size:1.6vw;color:#9fe6b5}
</style></head>
<body><div>
<h1>STORAGE OK</h1>
<p>The player is reading this card and running <code>autorun.brs</code>.</p>
<p id="js">JavaScript: running</p>
<p id="mods">BrightSign JS modules: checking…</p>
<p id="net">Server reachable: checking…</p>
</div>
<script>
// Each line below removes one suspect from the list, in the order they matter.
try {
var ok = [];
['messageport','registry','deviceinfo','syncmanager'].forEach(function (m) {
try { require('@brightsign/' + m); ok.push(m); } catch (e) {}
});
document.getElementById('mods').textContent =
ok.length ? 'BrightSign JS modules: ' + ok.join(', ') : 'BrightSign JS modules: NONE (brightsign_js_objects_enabled?)';
} catch (e) {
document.getElementById('mods').textContent = 'BrightSign JS modules: require() unavailable';
}
fetch('https://screentinker.com/api/status', { cache: 'no-store' })
.then(function (r) { document.getElementById('net').textContent = 'Server reachable: YES (HTTP ' + r.status + ')'; })
.catch(function (e) { document.getElementById('net').textContent = 'Server reachable: NO — ' + e.message; });
</script>
</body></html>

1163
brightsign/st-bridge.js Normal file

File diff suppressed because it is too large Load diff

169
brightsign/st-sync.js Normal file
View file

@ -0,0 +1,169 @@
/*
* ScreenTinker BrightSign native synchronisation (SyncManager).
*
* The alternative to our own clock-derived group sync, for groups where every member is a
* BrightSign. Frame-accurate, because it is the player's own video pipeline doing the aligning:
* setSyncParams() is a BrightSign extension on the standard <video> element, and once it is set
* the element keeps itself in step without further help.
*
* Shape of the protocol (docs.brightsign.biz/developers/syncmanager, and the dev-cookbook
* examples/browser/syncmanager-js example):
*
* - One member is the LEADER. It calls synchronize(id, msDelay), which multicasts a timestamped
* event. Everyone else listens. Ours is leaderless; this one is not, so the group needs a
* designated leader and goes unsynchronised if that member is off.
* - The leader receives its OWN broadcast and starts from it too. That is what stops it running
* ahead of the followers by the width of the network.
* - synchronize() REPEATS AT 1Hz so a player powered on late still joins the session. Acting on
* every repeat would reload the video once a second forever, which on screen looks like a
* stutter or a restart loop rather than a sync fault. Dedupe on the id mandatory.
* - Multicast, so every member must share one L2 network. A group spanning sites or VLANs
* cannot use this at all.
*
* Requires BrightSignOS 8.2.10+, and networking/ptp_domain = "0" (autorun.brs applies that, with
* the one reboot it needs).
*
* Video only: images and widgets have no setSyncParams, so they get item-boundary alignment from
* the sync event and nothing finer. That is a real functional difference from our own protocol,
* not just an accuracy one.
*
* Safe to load anywhere with no SyncManager module every method is a no-op and available()
* reports false, so the player falls back to its own sync.
*/
(function (global) {
'use strict';
// The cookbook's defaults. Kept as defaults rather than constants so a site with its own
// multicast policy can be pointed elsewhere without a code change.
var DEFAULTS = {
networkInterface: '', // '' = let the OS choose
domain: 'ScreenTinkerSync',
multicastAddress: '224.0.126.10',
multicastPort: 1539,
prepareMs: 1000 // lead time so every member can load before playback starts
};
function tryRequire(name) {
try {
if (typeof require !== 'function') return null;
return require(name);
} catch (e) { return null; }
}
var SyncManagerClass = tryRequire('@brightsign/syncmanager');
function Sync(options) {
var opts = options || {};
this.config = {
networkInterface: opts.networkInterface !== undefined ? opts.networkInterface : DEFAULTS.networkInterface,
domain: opts.domain || DEFAULTS.domain,
multicastAddress: opts.multicastAddress || DEFAULTS.multicastAddress,
multicastPort: opts.multicastPort || DEFAULTS.multicastPort,
prepareMs: opts.prepareMs !== undefined ? opts.prepareMs : DEFAULTS.prepareMs
};
this.sm = null;
this.isLeader = false;
this.lastId = null; // the dedupe that makes the 1Hz repeat harmless
this.onItem = null; // (syncEvent) => void, fired once per NEW id
this.lastEvent = null;
}
Sync.prototype.available = function () { return !!SyncManagerClass; };
/*
* Join the sync session. `leader` designates this member as the one that broadcasts.
* Returns false when the module is absent, so the caller can fall back rather than assume.
*/
Sync.prototype.start = function (leader) {
if (!SyncManagerClass) return false;
if (this.sm) this.stop();
try {
this.sm = new SyncManagerClass(
this.config.networkInterface,
this.config.domain,
this.config.multicastAddress,
this.config.multicastPort
);
} catch (e) {
this.sm = null;
return false;
}
this.isLeader = !!leader;
try {
// A follower must NOT set this. Assigning false is harmless per the API, but the examples
// simply omit it on followers, so match that.
if (this.isLeader) this.sm.leader = true;
this.sm.encrypted = false;
} catch (e) { /* an older build may not expose every property */ }
var self = this;
try {
this.sm.addEventListener('syncevent', function (e) { self._onEvent(e); });
} catch (e) {
this.stop();
return false;
}
return true;
};
/* Internal. Both roles land here — including the leader, for its own broadcast. */
Sync.prototype._onEvent = function (e) {
if (!e || e.id === undefined || e.id === null) return;
// THE trap: synchronize() repeats at 1Hz so late players can join. Only the first occurrence
// of an id is a new session; every repeat after it must be ignored or the video reloads
// once a second, forever.
if (e.id === this.lastId) return;
this.lastId = e.id;
this.lastEvent = e;
if (typeof this.onItem === 'function') {
try { this.onItem(e); } catch (err) { /* a bad handler must not kill the session */ }
}
};
/*
* Bind a video element to the current sync session. Call from the onItem handler.
* After this the element keeps itself aligned; nothing further is required per frame.
*/
Sync.prototype.attachVideo = function (video, event) {
var ev = event || this.lastEvent;
if (!video || !ev) return false;
if (typeof video.setSyncParams !== 'function') return false; // not a BrightSign <video>
try {
video.setSyncParams(ev.domain, ev.id, ev.iso_timestamp);
video.load();
var p = video.play();
if (p && typeof p.catch === 'function') p.catch(function () { /* autoplay guard */ });
return true;
} catch (e) { return false; }
};
/*
* LEADER ONLY: open a new sync session for a playlist item. `itemKey` should identify the item
* so the id changes on every advance a repeated id would be swallowed by the dedupe above and
* the group would sit on the previous item.
*/
Sync.prototype.announce = function (itemKey, nowMs) {
if (!this.sm || !this.isLeader) return false;
var id = 'st_' + String(itemKey) + '_' + String(nowMs || Date.now());
try {
this.sm.synchronize(id, this.config.prepareMs);
return id;
} catch (e) { return false; }
};
Sync.prototype.stop = function () {
if (!this.sm) return;
try { if (typeof this.sm.close === 'function') this.sm.close(); } catch (e) { /* ignore */ }
this.sm = null;
this.lastId = null;
this.lastEvent = null;
};
global.ScreenTinkerBSSync = {
create: function (options) { return new Sync(options); },
available: function () { return !!SyncManagerClass; },
DEFAULTS: DEFAULTS
};
})(typeof window !== 'undefined' ? window : this);

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.0
version: 1.9.36
description: |
Public, token-scoped REST API for ScreenTinker digital signage.
@ -109,6 +109,70 @@ components:
created_at:
type: integer
# --- Network addresses -------------------------------------------------------------
# Two different addresses, easy to confuse, so both are spelled out. They answer
# different questions and either can be null.
ip_address:
type: [string, "null"]
description: |
The device's **public (WAN)** address as observed by the server when the player
connected — i.e. what the internet sees. Behind a reverse proxy this is taken from
the first `X-Forwarded-For` entry, otherwise the socket peer address. Every device
on one site normally shares this. Null until the device has connected at least once.
local_ip:
type: [string, "null"]
description: |
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, 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
# never reported, and for platforms that cannot supply a given metric — treat every
# field here as optional rather than assuming a web player reports what Android does.
wifi_ssid:
type: [string, "null"]
description: |
Wi-Fi network name, or null on a wired/unknown connection.
Special value `"permission"` means the device is on Wi-Fi but the operating system
withheld the name — on Android 10+ reading the SSID requires a location permission
that ScreenTinker only asks for if an operator opts in. Treat `"permission"` as
"connected, name unavailable", not as a network literally called that.
wifi_rssi:
type: [integer, "null"]
description: Signal strength in dBm (negative; closer to zero is stronger).
battery_level:
type: [integer, "null"]
description: Battery percentage 0-100, or null on mains-powered hardware.
battery_charging:
type: [integer, "null"]
description: 1 charging, 0 not charging, null unknown.
storage_free_mb:
type: [integer, "null"]
storage_total_mb:
type: [integer, "null"]
ram_free_mb:
type: [integer, "null"]
ram_total_mb:
type: [integer, "null"]
cpu_usage:
type: [number, "null"]
description: Recent CPU utilisation as a percentage, where the platform exposes it.
uptime_seconds:
type: [integer, "null"]
description: Seconds since the device booted.
Playlist:
type: object
properties:
@ -619,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:
@ -761,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. }
@ -1188,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:
@ -1506,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.

349
docs/player-parity.md Normal file
View file

@ -0,0 +1,349 @@
# Player parity matrix
What each player can actually do, verified against the code rather than assumed. This is the
document that says where the remaining work is, so a wrong "yes" here is worse than a missing row:
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** — ✅ 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. 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 (`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` | ✅ `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` | ✅ `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` — 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` | ⚠️ `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` | ✅ `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. Tizen and BrightSign both
decline these explicitly and in writing in their own capability modules.
| capability | Android | Web / Tizen / BrightSign |
|---|---|---|
| `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` | ✅ `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
Prioritised by how visible the failure is to an operator.
**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.** 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.
- **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.
---
## Baselines: what an un-updated display is assumed to be able to 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

@ -41,6 +41,7 @@ export const api = {
// and the re-adopt action that applies a snapshot onto a newly-paired device.
getRemovedDevices: () => request('/devices/removed'),
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
setDevicePin: (id, body) => request(`/devices/${id}/settings-pin`, { method: 'POST', body: JSON.stringify(body) }),
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
@ -193,6 +194,7 @@ export const api = {
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
clearDevicePlaylist: (device_id) => request(`/devices/${device_id}/playlist`, { method: 'DELETE' }),
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
@ -208,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 }) }),
@ -221,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`),
@ -242,6 +251,8 @@ export const api = {
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
adminListOrgs: () => request('/admin/orgs'),
// Platform-admin view: EVERY plan incl. hidden ones, with subscriber counts.
adminListPlans: () => request('/admin/plans'),
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
aiGetSettings: () => request('/ai/settings'),
@ -255,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

@ -24,7 +24,12 @@ export function computeSteps({ devices = [], content = [], playlists = [] } = {}
const hasPlaylist = playlists.length > 0;
// "On screen" is the only step that cannot be faked by creating an object and walking away:
// some screen has to actually be pointed at something.
const isAssigned = devices.some((d) => d.playlist_id || d.default_content_id || d.layout_id);
// default_content_id is deliberately NOT counted. No player reads it — grep the whole tree and
// it appears only in this checklist, the device route, the settings snapshot and the schema —
// so counting it ticked "content assigned" for a screen that goes on showing "waiting for
// content". A checklist that lies about the one thing it is there to confirm is worse than no
// checklist. The field itself is left alone; that is a separate decision.
const isAssigned = devices.some((d) => d.playlist_id || d.layout_id);
const steps = [
{

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

@ -126,6 +126,8 @@ export default {
'dashboard.prompt_group_name': 'Gruppenname:',
'dashboard.error_pairing_code': 'Geben Sie einen gültigen 6-stelligen Kopplungscode ein',
'dashboard.confirm_add_to_group': '{name} ist bereits in: {groups}\n\nAuch zu „{target}“ hinzufügen?',
'dashboard.confirm_move_to_group': '{name} ist derzeit in: {groups}\n\nNach "{target}" verschieben?',
'dashboard.toast.move_partial': 'Entfernen aus {group} fehlgeschlagen — Bildschirm ist noch in beiden',
'dashboard.confirm_assign_playlist': 'Playlist „{playlist}“ allen Geräten in „{group}“ zuweisen?',
'dashboard.confirm_destructive_command': '{cmd} alle {n} Geräte in „{group}“?\n\nDies kann nicht rückgängig gemacht werden.',
'dashboard.confirm_delete_group': 'Diese Gruppe löschen? Geräte sind nicht betroffen.',
@ -257,6 +259,9 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} Elemente',
'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',
'device.info.size_free': '{size} frei',
@ -343,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',
@ -759,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',
@ -865,6 +877,10 @@ export default {
'admin.access_denied_desc': 'Plattform-Admin-Zugriff erforderlich.',
'admin.all_users': 'Alle Benutzer',
'admin.plans': 'Abonnementpläne',
'admin.col.accounts': 'Konten',
'admin.col.screens': 'Bildschirme',
'admin.plan_hidden': 'ausgeblendet',
'admin.plan_orphaned': 'Konten mit einem nicht mehr vorhandenen Tarif',
'admin.system': 'System',
'admin.col.user': 'Benutzer',
'admin.col.auth': 'Auth',

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)
@ -207,6 +296,12 @@ export default {
'dashboard.group_sync.toast_on': 'Synchronized playback enabled',
'dashboard.group_sync.toast_off': 'Synchronized playback disabled',
'dashboard.group_sync.toast_resync': 'Resync sent to group',
'dashboard.group_sync.backend_auto': 'Sync: Auto',
'dashboard.group_sync.backend_screentinker': 'Sync: Standard',
'dashboard.group_sync.backend_brightsign': 'Sync: BrightSign',
'dashboard.group_sync.backend_hint': "Which synchronisation protocol this group uses. Standard works across every player type and keeps displays aligned to the second, with no leader and no internet needed. BrightSign is frame-accurate but only works when every display in the group is a BrightSign on the same network, and it synchronises video only. Auto picks BrightSign when the group can actually run it, and Standard otherwise.",
'dashboard.group_sync.toast_backend': 'Sync protocol updated',
'dashboard.group_sync.toast_downgraded': 'Saved, but this group cannot run that protocol:',
'dashboard.manage_tooltip': 'Add/remove devices',
'dashboard.delete_group_tooltip': 'Delete group',
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',
@ -223,6 +318,8 @@ export default {
'dashboard.prompt_group_name': 'Group name:',
'dashboard.error_pairing_code': 'Enter a valid 6-digit pairing code',
'dashboard.confirm_add_to_group': '{name} is already in: {groups}\n\nAdd it to "{target}" too?',
'dashboard.confirm_move_to_group': '{name} is currently in: {groups}\n\nMove it to "{target}"?',
'dashboard.toast.move_partial': 'Could not remove it from {group} — it is still in both',
'dashboard.confirm_assign_playlist': 'Assign playlist "{playlist}" to all devices in "{group}"?',
'dashboard.confirm_destructive_command': '{cmd} all {n} devices in "{group}"?\n\nThis cannot be undone.',
'dashboard.confirm_delete_group': 'Delete this group? Devices will not be affected.',
@ -238,6 +335,7 @@ export default {
'dashboard.toast.playlist_assigned_other': 'Playlist assigned to {n} devices',
'dashboard.toast.command_sent': '{cmd} sent to {sent}/{total} devices',
'dashboard.toast.command_sent_with_offline': '{cmd} sent to {sent}/{total} devices ({offline} offline)',
'dashboard.toast.command_unsupported_n': '{n} skipped — their players do not support it.',
// Content library
'content.title': 'Content Library',
@ -394,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',
@ -463,15 +564,36 @@ export default {
// Info cards
'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',
'device.info.size_free': '{size} free',
// "Player storage" rather than "Storage": on a browser-family player this is the widget's cache
// quota, not the device filesystem, and it sits in the same column as Android's real disk usage.
'device.info.player_storage': 'Player Storage',
'device.info.hardware_model': 'Model',
'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',
'device.info.brightsign_player': 'BrightSign',
'device.info.wifi': 'WiFi',
'device.info.uptime': 'Uptime',
'device.info.android_version': 'Android Version',
'device.info.app_version': 'App Version',
'device.pin.rotate': 'Rotate',
'device.pin.set': 'Set…',
'device.pin.rotate_confirm': 'Generate a new settings PIN for this display? The current PIN stops working immediately.',
'device.pin.set_prompt': 'New 6-digit settings PIN',
'device.pin.updated_live': 'PIN updated — the display has it now',
'device.pin.updated_offline': 'PIN saved — the display will pick it up when it reconnects',
'device.pin.failed': 'Could not update the PIN',
'device.info.settings_pin': 'Settings PIN',
'device.info.settings_pin_hint': 'On-device settings menu (2× Back)',
'device.info.screen_resolution': 'Screen Resolution',
@ -518,8 +640,19 @@ 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.',
'device.ota.hint': 'When off, this device is never offered an update — an MDM or operator owns its updates instead. Turn OFF for MDM-managed panels (e.g. Pivot/MAXHUB) so the app never shows a self-install dialog.',
'device.reboot_schedule.label': 'Nightly reboot',
'device.reboot_schedule.hint': 'Reboot this panel once a day at this device-local time (leave blank for off). A clean nightly reboot clears memory leaks and re-syncs the clock. Silent on device-owner panels; a no-op on panels that can\'t self-reboot.',
@ -550,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',
@ -616,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',
@ -637,10 +775,16 @@ 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',
'device.toast.command_no_ack': '{cmd} — no server response',
'device.toast.command_unsupported': '{cmd} — this player does not support it ({cap}). Reload the page to refresh the controls.',
'device.caps.title': 'Player capabilities',
'device.caps.declared': 'Reported by the player itself. Controls this display cannot honour are hidden.',
'device.caps.assumed': 'This player has not reported its capabilities, so the defaults for its platform are assumed. They update the next time it connects.',
'device.caps.none': 'The player reports it can do nothing.',
// Settings
'settings.title': 'Settings',
@ -697,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',
@ -1121,6 +1292,14 @@ export default {
'playlist.click_to_edit_desc': 'Click to edit description',
'playlist.add_content': '+ Add Content',
'playlist.delete_playlist': 'Delete Playlist',
'playlist.layout_fullscreen': 'Fullscreen — all content shares the whole screen',
'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.',
@ -1268,8 +1447,22 @@ 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',
'admin.col.screens': 'Screens',
'admin.plan_hidden': 'hidden',
'admin.plan_orphaned': 'Accounts on a plan that no longer exists',
'admin.system': 'System',
// #15: instance-level default branding
'admin.branding.title': 'Default branding',
@ -1489,6 +1682,7 @@ export default {
'layout.properties': 'Properties',
'layout.delete_zone': 'Delete Zone',
'layout.zone_n': 'Zone {n}',
'layout.rename': 'Layout name — click to rename',
'layout.prop.name': 'Name',
'layout.prop.x': 'X (%)',
'layout.prop.y': 'Y (%)',

View file

@ -126,6 +126,8 @@ export default {
'dashboard.prompt_group_name': 'Nombre del grupo:',
'dashboard.error_pairing_code': 'Ingresa un código de vinculación válido de 6 dígitos',
'dashboard.confirm_add_to_group': '{name} ya está en: {groups}\n\n¿Agregarlo también a "{target}"?',
'dashboard.confirm_move_to_group': '{name} está actualmente en: {groups}\n\n¿Moverla a "{target}"?',
'dashboard.toast.move_partial': 'No se pudo quitar de {group}: sigue en ambos',
'dashboard.confirm_assign_playlist': '¿Asignar la lista "{playlist}" a todos los dispositivos de "{group}"?',
'dashboard.confirm_destructive_command': '¿{cmd} todos los {n} dispositivos de "{group}"?\n\nEsto no se puede deshacer.',
'dashboard.confirm_delete_group': '¿Eliminar este grupo? Los dispositivos no se verán afectados.',
@ -287,6 +289,9 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} elementos',
'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',
'device.info.size_free': '{size} libres',
@ -373,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',
@ -789,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',
@ -895,6 +907,10 @@ export default {
'admin.access_denied_desc': 'Se requiere acceso de administrador de plataforma.',
'admin.all_users': 'Todos los usuarios',
'admin.plans': 'Planes de suscripción',
'admin.col.accounts': 'Cuentas',
'admin.col.screens': 'Pantallas',
'admin.plan_hidden': 'oculto',
'admin.plan_orphaned': 'Cuentas en un plan que ya no existe',
'admin.system': 'Sistema',
'admin.col.user': 'Usuario',
'admin.col.auth': 'Auth',

View file

@ -126,6 +126,8 @@ export default {
'dashboard.prompt_group_name': 'Nom du groupe :',
'dashboard.error_pairing_code': 'Saisissez un code d\'appairage valide à 6 chiffres',
'dashboard.confirm_add_to_group': '{name} est déjà dans : {groups}\n\nL\'ajouter aussi à « {target} » ?',
'dashboard.confirm_move_to_group': '{name} est actuellement dans : {groups}\n\nLe déplacer vers "{target}" ?',
'dashboard.toast.move_partial': 'Impossible de le retirer de {group} — il est encore dans les deux',
'dashboard.confirm_assign_playlist': 'Attribuer la liste « {playlist} » à tous les appareils de « {group} » ?',
'dashboard.confirm_destructive_command': '{cmd} les {n} appareils de « {group} » ?\n\nCette action est irréversible.',
'dashboard.confirm_delete_group': 'Supprimer ce groupe ? Les appareils ne seront pas affectés.',
@ -257,6 +259,9 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} éléments',
'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',
'device.info.size_free': '{size} libres',
@ -343,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é',
@ -759,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',
@ -865,6 +877,10 @@ export default {
'admin.access_denied_desc': 'Accès administrateur plateforme requis.',
'admin.all_users': 'Tous les utilisateurs',
'admin.plans': 'Plans d\'abonnement',
'admin.col.accounts': 'Comptes',
'admin.col.screens': 'Écrans',
'admin.plan_hidden': 'masqué',
'admin.plan_orphaned': "Comptes sur un forfait qui n'existe plus",
'admin.system': 'Système',
'admin.col.user': 'Utilisateur',
'admin.col.auth': 'Auth',

View file

@ -127,6 +127,8 @@ export default {
'dashboard.prompt_group_name': 'Nome gruppo:',
'dashboard.error_pairing_code': 'Inserisci un codice di associazione valido di 6 cifre',
'dashboard.confirm_add_to_group': '{name} è già presente in: {groups}\n\nAggiungerlo anche a "{target}"?',
'dashboard.confirm_move_to_group': '{name} è attualmente in: {groups}\n\nSpostarlo in "{target}"?',
'dashboard.toast.move_partial': 'Impossibile rimuoverlo da {group}: è ancora in entrambi',
'dashboard.confirm_assign_playlist': 'Assegnare la playlist "{playlist}" a tutti i dispositivi in "{group}"?',
'dashboard.confirm_destructive_command': 'Eseguire {cmd} su tutti i {n} dispositivi in "{group}"?\n\nL\'azione è irreversibile.',
'dashboard.confirm_delete_group': 'Eliminare questo gruppo? I dispositivi non verranno rimossi dal sistema.',
@ -273,6 +275,9 @@ export default {
// Info cards
'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',
'device.info.size_free': '{size} liberi',
@ -367,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',
@ -747,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',
@ -823,6 +835,10 @@ export default {
'admin.access_denied_desc': 'È richiesto l\'accesso come amministratore di piattaforma.',
'admin.all_users': 'Tutti gli Utenti',
'admin.plans': 'Piani di Abbonamento',
'admin.col.accounts': 'Account',
'admin.col.screens': 'Schermi',
'admin.plan_hidden': 'nascosto',
'admin.plan_orphaned': 'Account su un piano che non esiste più',
'admin.system': 'Sistema',
'admin.col.user': 'Utente',
'admin.col.auth': 'Autenticazione',

View file

@ -126,6 +126,8 @@ export default {
'dashboard.prompt_group_name': 'Nome do grupo:',
'dashboard.error_pairing_code': 'Digite um código de pareamento válido de 6 dígitos',
'dashboard.confirm_add_to_group': '{name} já está em: {groups}\n\nAdicionar também a "{target}"?',
'dashboard.confirm_move_to_group': '{name} está atualmente em: {groups}\n\nMover para "{target}"?',
'dashboard.toast.move_partial': 'Não foi possível remover de {group} — continua em ambos',
'dashboard.confirm_assign_playlist': 'Atribuir a playlist "{playlist}" a todos os dispositivos de "{group}"?',
'dashboard.confirm_destructive_command': '{cmd} todos os {n} dispositivos de "{group}"?\n\nIsso não pode ser desfeito.',
'dashboard.confirm_delete_group': 'Excluir este grupo? Os dispositivos não serão afetados.',
@ -257,6 +259,9 @@ export default {
'device.playlist_picker.with_auto': '{name} (auto) — {n} itens',
'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',
'device.info.size_free': '{size} livres',
@ -343,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',
@ -759,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',
@ -865,6 +877,10 @@ export default {
'admin.access_denied_desc': 'Acesso de admin da plataforma necessário.',
'admin.all_users': 'Todos os usuários',
'admin.plans': 'Planos de assinatura',
'admin.col.accounts': 'Contas',
'admin.col.screens': 'Ecrãs',
'admin.plan_hidden': 'oculto',
'admin.plan_orphaned': 'Contas num plano que já não existe',
'admin.system': 'Sistema',
'admin.col.user': 'Usuário',
'admin.col.auth': 'Auth',

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

@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
container.innerHTML = `

View file

@ -12,7 +12,20 @@ import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
import { mapMutationError } from './workspace-members.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
// #14: the platform user-management dropdown manages users.role (the
// PLATFORM-level role) only - workspace/org roles are managed in the members
@ -66,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>
@ -124,6 +146,7 @@ export async function render(container) {
loadUsers();
loadOrgs();
loadSsoOnlyRequests();
loadBranding();
loadPlans();
loadSystem();
@ -133,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;
@ -263,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>
@ -373,7 +466,10 @@ async function loadStatusDebug() {
async function loadPlans() {
const el = document.getElementById('plansTable');
try {
const plans = await fetch('/api/subscription/plans').then(r => r.json());
// Admin endpoint, not /api/subscription/plans: that one filters `active = 1` because it feeds
// the pricing page, so a deliberately hidden plan (a comped or beta tier) was invisible to the
// operator too. Here we want every plan, plus who is actually on each one.
const { plans, orphaned } = await api.adminListPlans();
el.innerHTML = `
<div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:500px">
@ -383,20 +479,31 @@ async function loadPlans() {
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.storage')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.monthly')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.yearly')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.accounts')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.screens')}</th>
</tr></thead>
<tbody>
${plans.map(p => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px;font-weight:500">${p.display_name}</td>
<tr style="border-bottom:1px solid var(--border)${p.active ? '' : ';opacity:.7'}">
<td style="padding:8px;font-weight:500">${esc(p.display_name)}
<span style="color:var(--text-muted);font-weight:400;font-size:11px">${esc(p.id)}</span>
${p.active ? '' : `<span style="margin-left:6px;font-size:10px;padding:1px 6px;border:1px solid var(--border);border-radius:8px;color:var(--text-muted)">${t('admin.plan_hidden')}</span>`}
</td>
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</td>
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
<td style="padding:8px;text-align:right${p.user_count ? ';font-weight:500' : ';color:var(--text-muted)'}">${p.user_count}</td>
<td style="padding:8px;text-align:right;color:var(--text-muted)">${p.device_count}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
${(orphaned && orphaned.length) ? `
<p style="margin-top:10px;color:var(--danger);font-size:12px">
${t('admin.plan_orphaned')}: ${orphaned.map(o => `<strong>${esc(o.plan_id)}</strong> (${o.user_count})`).join(', ')}
</p>` : ''}
`;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
}

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>
@ -714,6 +714,15 @@ function showEditModal(contentItem, onSave) {
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option>
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option>
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option>
${['video/mp4','video/webm','image/jpeg','image/png','image/gif','image/webp'].includes(contentItem.mime_type) ? '' : `
<!-- The item's ACTUAL type, for the cases the six choices above cannot express:
video/youtube, and uploads the sniffer accepts but this list omits (.mov, .svg,
.heic, .avif, .bmp). Without it no option matched, the browser selected the first
one - video/mp4 - and pressing Save with nothing else changed rewrote the item's
type. mime_type is the renderer selector in every player, so a YouTube item became
an "MP4" whose source is an embed page: a dead slide on every screen, and
unrecoverable here because there was no option to set it back. -->
<option value="${esc(contentItem.mime_type || '')}" selected>${esc(contentItem.mime_type || '')}</option>`}
</select>
</div>
<div class="form-group">

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>
@ -234,6 +269,14 @@ function renderGroupSection(group, devices, playlists) {
<input type="checkbox" class="group-sync-cb" data-group-id="${group.id}" ${group.sync_enabled ? 'checked' : ''}> ${t('dashboard.group_sync.label')}
</label>
${group.sync_enabled ? `
<select class="input group-backend-select" data-group-id="${group.id}" style="width:130px;padding:4px 8px;font-size:12px;background:var(--bg-input)" title="${esc(t('dashboard.group_sync.backend_hint'))}">
<option value="auto" ${(group.sync_backend || 'auto') === 'auto' ? 'selected' : ''}>${t('dashboard.group_sync.backend_auto')}</option>
<option value="screentinker" ${group.sync_backend === 'screentinker' ? 'selected' : ''}>${t('dashboard.group_sync.backend_screentinker')}</option>
<option value="brightsign" ${group.sync_backend === 'brightsign' ? 'selected' : ''}>${t('dashboard.group_sync.backend_brightsign')}</option>
</select>
${group.sync_effective ? `
<span style="font-size:11px;color:${group.sync_downgraded ? 'var(--warning, #d97706)' : 'var(--text-muted)'};white-space:nowrap"
title="${esc(group.sync_reason || '')}">${group.sync_downgraded ? '&#9888; ' : ''}${esc(group.sync_effective)}${group.sync_reason ? ' — ' + esc(group.sync_reason) : ''}</span>` : ''}
<button class="btn group-resync-btn" data-group-id="${group.id}" style="padding:4px 10px;font-size:12px" title="${esc(t('dashboard.group_sync.resync_hint'))}">${t('dashboard.group_sync.resync')}</button>` : ''}
` : ''}
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
@ -247,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">
@ -389,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
@ -397,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) => {
@ -409,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
});
};
@ -438,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() {
@ -651,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
@ -767,13 +861,28 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info');
return;
}
// If the device is in another group, mirror the Manage modal's confirm.
const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name);
// Dragging a screen onto a group MOVES it. This used to borrow the Manage modal's
// "add it too?" confirm and then only add — so the screen ended up in both groups while the
// toast claimed it had moved, the page still showed the old group, and a second attempt said
// "already in group 2". Reported by a customer doing exactly that with two screens.
// The Manage modal keeps add/remove checkboxes: multi-group membership is deliberate THERE.
// It is not deliberate here, and it is not harmless — deviceSyncGroup() picks arbitrarily
// when a device is in several sync-enabled groups, so a half-move leaves sync ambiguous.
const others = groupsByDeviceId.get(deviceId) || [];
if (others.length > 0) {
if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return;
if (!confirm(t('dashboard.confirm_move_to_group', {
name: deviceName, groups: others.map(g => g.name).join(', '), target: targetGroup.name,
}))) return;
}
try {
// Add first, then drop the old memberships: if the add fails the screen keeps the group it
// had rather than being left ungrouped by a half-finished move.
await api.addDeviceToGroup(groupId, deviceId);
for (const g of others) {
if (g.id === groupId) continue;
try { await api.removeDeviceFromGroup(g.id, deviceId); }
catch (e) { showToast(t('dashboard.toast.move_partial', { group: g.name }), 'warning'); }
}
showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success');
loadDashboard();
} catch (err) { showToast(err.message, 'error'); }
@ -847,6 +956,31 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
});
});
// Choose the sync protocol. The server may refuse the choice (native sync needs every member to
// be a BrightSign on one L2 network), so re-render from its answer rather than assuming the
// request took — showing a setting that isn't in force is exactly what makes a drifting wall
// impossible to diagnose.
document.querySelectorAll('.group-backend-select').forEach(sel => {
sel.addEventListener('change', async (e) => {
const groupId = e.target.dataset.groupId;
const previous = sel.dataset.previous || 'auto';
const chosen = e.target.value;
try {
const updated = await api.updateGroup(groupId, { sync_backend: chosen });
if (updated?.sync_downgraded && updated?.sync_reason) {
showToast(t('dashboard.group_sync.toast_downgraded') + ' ' + updated.sync_reason, 'warning');
} else {
showToast(t('dashboard.group_sync.toast_backend'), 'success');
}
loadDashboard();
} catch (err) {
showToast(err.message, 'error');
e.target.value = previous;
}
});
sel.dataset.previous = sel.value;
});
// #group-sync: manual "Resync now" — nudge all members to re-snap to the shared schedule.
document.querySelectorAll('.group-resync-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
@ -879,10 +1013,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
try {
const result = await api.sendGroupCommand(groupId, type);
const msg = result.offline > 0
// A group is routinely mixed-platform, so these buttons stay visible — "reboot" is
// meaningful for the Android panels in the group even when the web players in it can
// never honour it. What must not happen is the toast counting those as sent: the
// operator would walk away believing the whole group rebooted.
let msg = result.offline > 0
? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline })
: t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total });
showToast(msg, result.offline > 0 ? 'warning' : 'success');
if (result.unsupported > 0) {
msg += ' ' + t('dashboard.toast.command_unsupported_n', { n: result.unsupported });
}
showToast(msg, (result.offline > 0 || result.unsupported > 0) ? 'warning' : 'success');
} catch (err) {
showToast(err.message, 'error');
}

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,25 @@ 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
// customer reasonably read the blank as a bug in the player. "permission" means we are not
// allowed to know; empty means there is genuinely no Wi-Fi (an Ethernet panel).
function ssidLabel(ssid) {
if (ssid === 'permission') return esc(t('device.info.wifi_needs_location'));
if (!ssid) return '--';
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;
@ -14,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).
@ -73,6 +167,49 @@ function renderDeviceClock(device) {
return `${tz}${local ? `<div style="font-size:11px;color:var(--text-muted)">${t('device.clock.reported', { time: local })}</div>` : ''}${warn}`;
}
// A BrightSign runs the same web player, so client_type is 'player' and it would otherwise read as
// "Web Player" — indistinguishable from a browser tab on someone's desk. The player reports
// platform 'brightsign' (autorun.brs puts ?platform=brightsign on the URL); the user-agent check
// covers panels paired before that existed, which registered as "Chrome 120" with a BrightSign UA.
function isBrightSignDevice(device) {
if (!device) return false;
// platform only: `devices` has no user_agent column, so a fallback on it could never fire.
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">
@ -124,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');
@ -151,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);
@ -172,26 +315,43 @@ async function loadDevice(deviceId, activeTab = null) {
try {
const device = await api.getDevice(deviceId);
currentDevice = device;
/*
* Does this display support `cap`? Drives which controls render at all.
*
* Every control used to be offered to every display: a browser tab was shown "Reboot device",
* a Tizen TV was shown screen power. They did nothing, silently, and read as bugs. Hidden
* rather than disabled a greyed-out button on a panel that will NEVER gain the capability is
* a permanent question ("what do I have to do to enable this?") with no answer. The capability
* list is shown in the Info tab so a missing control is explainable.
*
* The server resolves the baseline for the ~440 displays that declare nothing, so this sees a
* populated list either way and never has to know the difference.
*/
const caps = Array.isArray(device.capabilities) ? device.capabilities : null;
const can = (cap) => (caps ? caps.includes(cap) : true); // no list at all => pre-capability server, show everything
const latestTelemetry = device.telemetry?.[0] || {};
const diagWidget = (device.assignments || []).find(a => a && a.widget_type === 'diag-smoothness');
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>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="devicePreviewBtn">${t('device.preview_btn')}</button>
<button class="btn btn-secondary btn-sm" id="renameBtn">${t('device.rename')}</button>
${can('remote.screenshot') ? `
<button class="btn btn-secondary btn-sm" id="screenshotBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
${t('device.screenshot_btn')}
</button>
</button>` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? `
<button class="btn btn-secondary btn-sm" id="deviceOwnerBtn" title="${t('device.owner_provision.tip')}">${t('device.owner_provision.btn')}</button>` : ''}
<button class="btn btn-secondary btn-sm" id="blockDeviceBtn">${device.blocked ? 'Unblock' : 'Block'}</button>
@ -199,27 +359,31 @@ async function loadDevice(deviceId, activeTab = null) {
</div>
</div>
${device.tier === 2 ? `
${/* tier===2 is kept alongside the capability: it is already an accurate RUNTIME signal from
the panel, and a device-owner display that has not yet shipped a capability declaration
would otherwise lose these buttons the day this deploys. */
(device.tier === 2 || can('system.device_owner')) ? `
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:8px 0 4px" title="${t('device.tier2.tip')}">
<span style="font-size:12px;color:var(--text-muted)">${t('device.tier2.label')}</span>
<button class="btn btn-secondary btn-sm" id="t2Reboot">${t('device.tier2.reboot')}</button>
<button class="btn btn-secondary btn-sm" id="t2Lock">${t('device.tier2.lock')}</button>
${(device.tier === 2 || can('system.kiosk')) ? `
<button class="btn btn-secondary btn-sm" id="t2KioskOn">${t('device.tier2.kiosk_on')}</button>
<button class="btn btn-secondary btn-sm" id="t2KioskOff">${t('device.tier2.kiosk_off')}</button>
<button class="btn btn-secondary btn-sm" id="t2KioskOff">${t('device.tier2.kiosk_off')}</button>` : ''}
</div>` : ''}
<div class="tabs">
<div class="tab active" data-tab="nowplaying">${t('device.tab.now_playing')} <span class="help-tip" data-tip="${t('device.tab.now_playing_tip')}">?</span></div>
<div class="tab" data-tab="playlist">${t('device.tab.playlist')} <span class="help-tip" data-tip="${t('device.tab.playlist_tip')}">?</span></div>
<div class="tab" data-tab="info">${t('device.tab.info')} <span class="help-tip" data-tip="${t('device.tab.info_tip')}">?</span></div>
<div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>
${(device.client_type === 'apk' || device.android_version) ? `<div class="tab" data-tab="controls">${t('device.tab.controls')} <span class="help-tip" data-tip="${t('device.tab.controls_tip')}">?</span></div>` : ''}
${(can('remote.stream') || can('remote.input') || can('remote.screenshot')) ? `<div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>` : ''}
${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `<div class="tab" data-tab="controls">${t('device.tab.controls')} <span class="help-tip" data-tip="${t('device.tab.controls_tip')}">?</span></div>` : ''}
${device.tier === 2 ? `<div class="tab" data-tab="terminal">${t('device.tab.terminal')} <span class="help-tip" data-tip="${t('device.tab.terminal_tip')}">?</span></div>` : ''}
</div>
<!-- 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">
@ -228,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>
@ -293,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>
@ -302,6 +526,22 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.ip_address')}</div>
<div class="info-card-value small">${device.ip_address || '--'}</div>
</div>
<div class="info-card">
<!-- Two different addresses, and conflating them confused a customer into reading their
ISP's address as the screen's. Above is where the connection comes FROM (public);
this is what the screen calls itself on its own network. -->
<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>
@ -324,13 +564,64 @@ async function loadDevice(deviceId, activeTab = null) {
` : `
<div class="info-card">
<div class="info-card-label">${t('device.info.player_type')}</div>
<div class="info-card-value small">${t('device.info.web_player')}</div>
<div class="info-card-value small">${isBrightSignDevice(device) ? t('device.info.brightsign_player') : t('device.info.web_player')}</div>
</div>
${device.hardware_model ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.hardware_model')}</div>
<div class="info-card-value small">${esc(device.hardware_model)}${device.output_index > 1 ? ` <span style="color:var(--text-muted)">${t('device.info.output_n', { n: device.output_index })}</span>` : ''}</div>
</div>` : ''}
${device.hardware_os_version ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.os_version')}</div>
<div class="info-card-value small">${esc(device.hardware_os_version)}</div>
</div>` : ''}
${device.hardware_serial ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.serial')}</div>
<div class="info-card-value small">${esc(device.hardware_serial)}</div>
</div>` : ''}
${latestTelemetry.storage_total_mb ? `
<div class="info-card">
<!-- 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'}"
style="width:${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb * 100)}%"></div>
</div>
</div>` : ''}
`}
${latestTelemetry.temperature_c != null ? `
<div class="info-card">
<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>
<div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div>
<div class="info-card-value small" id="telWifi">${ssidLabel(latestTelemetry.wifi_ssid)}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div>
</div>
` : ''}
@ -351,6 +642,10 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.settings_pin')}</div>
<div class="info-card-value small" style="font-family:monospace;letter-spacing:1px">${device.settings_pin || '--'}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('device.info.settings_pin_hint')}</div>
<div style="display:flex;gap:6px;margin-top:6px">
<button class="btn btn-secondary btn-sm" id="rotatePinBtn">${t('device.pin.rotate')}</button>
<button class="btn btn-secondary btn-sm" id="setPinBtn">${t('device.pin.set')}</button>
</div>
</div>
` : ''}
<div class="info-card">
@ -368,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>
@ -380,6 +682,22 @@ async function loadDevice(deviceId, activeTab = null) {
` : ''}
</div>
<!-- What this display can do.
Controls are now hidden when the player cannot honour them, which on its own looks
like the dashboard has lost features. This is the answer to "where did the reboot
button go" it names the exact set the panel reported, and says plainly when the set
is a per-platform assumption rather than something the player actually declared. -->
<div style="margin-top:20px">
<h4 style="font-size:13px;margin-bottom:8px">${t('device.caps.title')}</h4>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
${caps ? t('device.caps.declared') : t('device.caps.assumed')}
</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${(device.capabilities || []).map(c => `<span style="font-family:monospace;font-size:11px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;padding:2px 6px">${esc(c)}</span>`).join('')
|| `<span style="font-size:12px;color:var(--danger)">${t('device.caps.none')}</span>`}
</div>
</div>
<!-- Uptime Timeline (24h) -->
<div style="margin-top:20px">
<h4 style="font-size:13px;margin-bottom:8px">${t('device.timeline.title')}</h4>
@ -429,6 +747,10 @@ async function loadDevice(deviceId, activeTab = null) {
<input type="checkbox" id="otaToggle" ${device.ota_enabled === 0 ? '' : 'checked'}> ${t('device.ota.toggle')}
</label>
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.ota.hint')}</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;margin-top:8px">
<input type="checkbox" id="otaBetaToggle" ${device.ota_beta === 1 ? 'checked' : ''}> ${t('device.ota.beta')}
</label>
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.ota.beta_hint')}</div>
</div>
<div class="form-group" style="max-width:280px">
<label>${t('device.reboot_schedule.label')}</label>
@ -444,47 +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">
<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>
<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>
<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>
<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>
<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-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). -->
@ -510,10 +804,17 @@ async function loadDevice(deviceId, activeTab = null) {
</div>
</div>
${(can('remote.stream') || can('remote.input') || can('remote.screenshot')) ? `
<!-- Remote Control Tab -->
<div class="tab-content" id="tab-remote">
<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">
@ -525,12 +826,14 @@ async function loadDevice(deviceId, activeTab = null) {
<p style="color:var(--text-secondary)">${t('device.remote.start_prompt')}</p>
</div>
</div>
</div>
</div>` : ''}
<div class="remote-controls">
${can('remote.stream') ? `
<button class="btn btn-primary" id="startRemoteBtn">${t('device.remote.start')}</button>
<button class="btn btn-secondary" id="stopRemoteBtn" style="display:none">${t('device.remote.stop')}</button>
<hr style="border-color:var(--border);margin:8px 0">
<!-- Always available -->
<hr style="border-color:var(--border);margin:8px 0">` : ''}
${can('remote.input') ? `
<!-- Key pad -->
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_UP')">${t('device.remote.vol_up')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">${t('device.remote.vol_down')}</button>
<hr style="border-color:var(--border);margin:8px 0">
@ -551,33 +854,37 @@ 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>` : ''}
</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>
` : `
${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>
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>`}
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>` : ''}`}
</div>
</div>
</div>
</div>` : ''}
${(device.client_type === 'apk' || device.android_version) ? `
${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `
<!-- Controls Tab (#160 Track-A system control no device owner needed) -->
<div class="tab-content" id="tab-controls">
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">${t('device.sysctl.subtitle')}</div>
<div style="display:grid;grid-template-columns:130px 1fr;gap:14px 14px;align-items:center;font-size:13px;max-width:480px">
${can('audio.volume') ? `
<label>${t('device.sysctl.volume')}</label>
<input type="range" min="0" max="100" value="${Math.round((device.media_volume != null ? device.media_volume : 0.5) * 100)}" id="sysVolume" style="width:100%">
<input type="range" min="0" max="100" value="${Math.round((device.media_volume != null ? device.media_volume : 0.5) * 100)}" id="sysVolume" style="width:100%">` : ''}
${can('display.brightness') ? `
<label>${t('device.sysctl.brightness_window')}</label>
<input type="range" min="5" max="100" value="${Math.round((device.window_brightness != null && device.window_brightness >= 0 ? device.window_brightness : 1) * 100)}" id="sysWinBrightness" style="width:100%">
${(device.can_write_settings || device.tier === 2) ? `
<input type="range" min="5" max="100" value="${Math.round((device.window_brightness != null && device.window_brightness >= 0 ? device.window_brightness : 1) * 100)}" id="sysWinBrightness" style="width:100%">` : ''}
${(device.can_write_settings || device.tier === 2 || can('system.brightness') || can('system.screen_timeout')) ? `
<label>${t('device.sysctl.brightness_system')}</label>
<input type="range" min="5" max="100" value="${Math.round((device.system_brightness != null ? device.system_brightness : 0.8) * 100)}" id="sysBrightness" style="width:100%">
<label>${t('device.sysctl.sleep')}</label>
@ -679,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);
@ -688,9 +996,14 @@ async function loadDevice(deviceId, activeTab = null) {
if (activeTab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
const tab = document.querySelector(`.tab[data-tab="${activeTab}"]`);
// Both loops above just cleared every tab, so a requested tab that no longer renders (its
// capability went away, or the page was reloaded against a player that has since declared a
// smaller set) would leave NO tab selected and the page blank. Fall back to Info, which is
// never gated.
const wanted = document.getElementById(`tab-${activeTab}`) ? activeTab : 'info';
const tab = document.querySelector(`.tab[data-tab="${wanted}"]`);
if (tab) tab.classList.add('active');
const content = document.getElementById(`tab-${activeTab}`);
const content = document.getElementById(`tab-${wanted}`);
if (content) content.classList.add('active');
}
@ -783,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 = `
@ -794,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(); };
@ -904,12 +1225,43 @@ async function showReAdoptModal(device) {
function setupActions(device) {
// #104 Preview button
// PIN rotate / set. The response says whether the panel took it LIVE: an offline display
// applies it on its next reconnect, and an operator rotating a leaked PIN needs to know
// which of those happened rather than assuming access is already revoked.
async function applyPin(body, confirmMsg) {
if (confirmMsg && !confirm(confirmMsg)) return;
try {
const r = await api.setDevicePin(device.id, body);
device.settings_pin = r.settings_pin;
const el = document.querySelector('#rotatePinBtn')?.closest('.info-card')?.querySelector('.info-card-value');
if (el) el.textContent = r.settings_pin;
showToast(r.delivered ? t('device.pin.updated_live') : t('device.pin.updated_offline'), 'success');
} catch (e) {
showToast(e?.message || t('device.pin.failed'), 'error');
}
}
document.getElementById('rotatePinBtn')?.addEventListener('click', () =>
applyPin({ rotate: true }, t('device.pin.rotate_confirm')));
document.getElementById('setPinBtn')?.addEventListener('click', () => {
const pin = prompt(t('device.pin.set_prompt'));
if (pin === null) return;
applyPin({ pin });
});
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
@ -950,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, {
@ -960,6 +1339,7 @@ function setupActions(device) {
orientation: document.getElementById('deviceOrientation').value,
default_content_id: document.getElementById('deviceDefaultContent').value || null,
ota_enabled: document.getElementById('otaToggle')?.checked ? 1 : 0,
ota_beta: document.getElementById('otaBetaToggle')?.checked ? 1 : 0,
reboot_schedule: document.getElementById('rebootSchedule')?.value || null,
});
showToast(t('device.toast.settings_saved'), 'success');
@ -1022,10 +1402,15 @@ function setupActions(device) {
playlistPicker.addEventListener('change', async () => {
const newPlaylistId = playlistPicker.value;
if (!newPlaylistId) return; // Don't allow deselecting for now
try {
await api.assignPlaylistToDevice(newPlaylistId, device.id);
device.playlist_id = newPlaylistId;
// Empty value is the "No playlist" option. It used to be discarded right here, so the
// option was offered, selecting it did nothing, and nothing said so (#234).
if (newPlaylistId) {
await api.assignPlaylistToDevice(newPlaylistId, device.id);
} else {
await api.clearDevicePlaylist(device.id);
}
device.playlist_id = newPlaylistId || null;
const assignments = await api.getAssignments(device.id);
const pc = document.getElementById('playlistContainer');
pc.innerHTML = renderPlaylist(assignments);
@ -1132,13 +1517,18 @@ function setupActions(device) {
}, 3000);
});
// Send a command and surface the three-state ack as a toast.
// Send a command and surface the ack as a toast.
// - delivered: device received it (green/success)
// - queued: device is offline, will deliver on reconnect (amber/warning)
// - unsupported: the player cannot do this at all (red/error, names the capability)
// - no_ack / fallback: server didn't respond or queue unavailable (red/error)
function sendWithFeedback(type, cmdLabel, successKey) {
sendCommand(device.id, type, {}, (ack) => {
if (ack?.delivered) showToast(t(successKey), 'success');
// Reachable from a stale tab rendered before the panel declared its capabilities: the
// button was there when the page loaded and is gone on reload. Say why rather than
// showing the generic "undeliverable", which reads as a network problem.
else if (ack?.reason === 'unsupported') showToast(t('device.toast.command_unsupported', { cmd: cmdLabel, cap: ack.capability || '' }), 'error');
else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning');
else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error');
else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error');
@ -1209,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();
@ -1391,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>
@ -1403,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">
@ -1414,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
@ -1438,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>
@ -1447,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>
@ -1474,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;
});
});
@ -1868,7 +2275,11 @@ function updateTelemetryDisplay(telemetry) {
};
if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%');
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid);
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) }));
@ -1945,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

@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const hash = window.location.hash;

View file

@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
import { t, tn } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const hash = window.location.hash;
@ -104,6 +117,20 @@ function renderLayoutCard(layout, isTemplate) {
`;
}
/*
* Canvas aspect as a padding-top percentage: the layout's own height/width.
*
* Falls back to 16:9 when a layout carries no usable dimensions, and clamps so a pathological
* value cannot produce a canvas taller than the screen or thinner than a line these rows are
* user-editable, and an unusable editor is worse than a slightly wrong aspect.
*/
function canvasRatioPct(layout) {
const w = Number(layout && layout.width) || 1920;
const h = Number(layout && layout.height) || 1080;
if (!(w > 0 && h > 0)) return 56.25;
return Math.min(300, Math.max(20, (h / w) * 100));
}
async function renderEditor(container, layoutId) {
let layout;
try {
@ -116,7 +143,12 @@ async function renderEditor(container, layoutId) {
${t('layout.back')}
</a>
<div class="page-header">
<h1 id="layoutName">${esc(layout.name)}</h1>
<!-- Editable in place. Duplicating a template names the copy "<template> (Copy)" and there
was nowhere at all to change it the only name field in this editor belongs to the
selected ZONE, which is easy to mistake for the layout's own. Reported on #234. -->
<input id="layoutName" class="input" value="${esc(layout.name)}"
aria-label="${t('layout.rename')}" title="${t('layout.rename')}"
style="font-size:24px;font-weight:600;background:transparent;border:1px solid transparent;padding:2px 6px;max-width:420px">
<div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
@ -125,7 +157,10 @@ async function renderEditor(container, layoutId) {
<div style="display:flex;gap:20px">
<div style="flex:1">
<div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden">
<div id="canvas" style="position:relative;width:100%;padding-top:56.25%">
<!-- Canvas mirrors THIS layout's shape, not a fixed 16:9. It was hardcoded to 56.25%
(the padding-ratio trick for 16:9), so authoring a portrait layout meant dragging
zones on a landscape canvas: correct on the panel, wrong everywhere you designed it. -->
<div id="canvas" style="position:relative;width:100%;padding-top:${canvasRatioPct(layout)}%">
</div>
</div>
</div>
@ -297,9 +332,12 @@ async function renderEditor(container, layoutId) {
// exactly. The old per-zone delete-then-add loop could accumulate zones
// (and regenerated every zone id each save). Keep each zone's id so
// device->zone assignments survive.
const newName = (document.getElementById('layoutName')?.value || '').trim();
const updated = await API(`/layouts/${layoutId}`, {
method: 'PUT',
body: JSON.stringify({ zones }),
// Name goes with the zones so renaming is part of the Save the user already
// presses, not a second hidden action.
body: JSON.stringify(newName ? { zones, name: newName } : { zones }),
});
if (updated && updated.error) { showToast(updated.error, 'error'); return; }
layout = updated;

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,150 @@ 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);
}
/*
* A small picture of where this playlist's content actually lands.
*
* A playlist has no intrinsic layout the server derives one from the items' own zone bindings
* (#104) so the page could previously show an item tagged "Bottom Ticker" with no indication
* that the ticker is a thin strip along the bottom. People assigned content to zones by name and
* found out where it went by looking at a screen.
*
* Drawn from the zone percentages, so it is correct for any layout including portrait ones without
* a stored thumbnail. Zones with no items are dimmed: an empty zone on a real panel shows its
* background colour, and that is worth seeing BEFORE publishing rather than after.
*/
function layoutMockup(playlist) {
const layout = playlist && playlist.layout;
const items = (playlist && playlist.items) || [];
// No layout means fullscreen — every item shares one frame. Drawing a single empty box would
// imply a choice was made; say it in words instead.
if (!layout || !Array.isArray(layout.zones) || layout.zones.length === 0) {
return `<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('playlist.layout_fullscreen')}</div>`;
}
const counts = {};
for (const it of items) if (it.zone_id) counts[it.zone_id] = (counts[it.zone_id] || 0) + 1;
const w = Number(layout.width) || 1920;
const h = Number(layout.height) || 1080;
const portrait = h > w;
// Fixed short edge, long edge derived — a portrait mockup must not be as wide as a landscape one
// or it dominates the page.
const boxW = portrait ? 90 : 200;
const boxH = Math.round(boxW * (h / w));
const zones = layout.zones.map((z) => {
const n = counts[z.id] || 0;
const filled = n > 0;
return `<div title="${esc(z.name)}${filled ? `${n}` : ''}" style="
position:absolute;
left:${z.x_percent}%; top:${z.y_percent}%;
width:${z.width_percent}%; height:${z.height_percent}%;
box-sizing:border-box;
border:1px solid ${filled ? 'var(--accent)' : 'var(--border)'};
background:${filled ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent'};
display:flex;align-items:center;justify-content:center;
font-size:9px;line-height:1;color:var(--text-muted);overflow:hidden;
">${filled ? n : ''}</div>`;
}).join('');
return `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="position:relative;width:${boxW}px;height:${boxH}px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;flex:none">
${zones}
</div>
<div style="font-size:12px;color:var(--text-muted)">
<div>${esc(layout.name || '')} &middot; ${w}&times;${h}${portrait ? ' (portrait)' : ''}</div>
<div>${tn('playlist.zones_count', layout.zones.length)}</div>
${layout._preview_ambiguous ? `<div style="color:var(--warning)">${t('playlist.layout_ambiguous')}</div>` : ''}
</div>
</div>`;
}
function renderDetailContent(container, playlist) {
@ -288,6 +415,8 @@ function renderDetailContent(container, playlist) {
</div>
</div>
${layoutMockup(playlist)}
<div id="playlistItems" style="display:flex;flex-direction:column;gap:8px">
</div>
`;
@ -672,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

@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const devices = await api.getDevices();
@ -26,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>
@ -169,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

@ -8,7 +8,20 @@ import {
dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN,
} from '../lib/schedule-grid.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
// Teardown registered during render (resize listener, etc). Declared here rather than beside
// cleanup() so it is initialised before any render can push to it.

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

@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
import { t, tn } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const hash = window.location.hash;

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

@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { hydrateAuthImages } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
// Widget type ids only — name + desc are looked up via t() so they switch
// language with the rest of the UI.
@ -200,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">
@ -294,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>`;
@ -387,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>`;
@ -416,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);

111
scripts/build-autorun-zip.sh Executable file
View file

@ -0,0 +1,111 @@
#!/bin/bash
# Build brightsign/autorun.zip — the single-file installer for a BrightSign player.
#
# scripts/build-autorun-zip.sh [--server https://your-server] [-o path/to/autorun.zip]
#
# Drop the resulting autorun.zip on the root of a player's storage (microSD, USB, or internal
# flash over SFTP) and power-cycle. autozip.brs unpacks it in place, marks it done, and reboots
# into the player. One file to distribute instead of four that must all land intact.
#
# ⚠️ The zip must expand to files AT ITS ROOT — no wrapper directory. A player extracts to the
# storage root, so a nested folder puts autorun.brs somewhere the player never looks and the
# card silently does nothing. That is why this zips from *inside* the staging directory.
#
# ⚠️ autorun.brs must NOT sit next to autorun.zip on the storage root: its presence stops the zip
# being processed at all. It belongs inside, which is where this puts it.
set -euo pipefail
cd "$(dirname "$0")/.."
SERVER=""
OUT="brightsign/autorun.zip"
while [ $# -gt 0 ]; do
case "$1" in
--server) SERVER="${2:-}"; shift 2 ;;
-o|--out) OUT="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
command -v zip >/dev/null || { echo "ERROR: 'zip' is not installed." >&2; exit 1; }
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# The payload. autozip.brs must be here too: it is what the NEXT player to receive this archive
# runs, and it has to survive being extracted alongside everything else.
cp brightsign/autozip.brs "$STAGE/"
cp brightsign/autorun.brs "$STAGE/"
cp brightsign/offline.html "$STAGE/"
cp brightsign/screentinker.json "$STAGE/"
# Stamp the version into the host so the script REPORTS the version it actually is. A package that
# ships reporting the old version is applied, reports the old version, and is offered again on the
# next check — forever. server/lib/brightsign-package.js does the identical substitution, anchored
# on the same ST_PACKAGE_VERSION marker, so a zip built here and one built by the server agree.
VERSION="$(cat VERSION 2>/dev/null | tr -d '[:space:]')"
if [ -n "$VERSION" ]; then
python3 - "$STAGE/autorun.brs" "$VERSION" <<'PY'
import re, sys
path, version = sys.argv[1], sys.argv[2]
src = open(path).read()
out = re.sub(r'return "[^"]*"(\s*\'\s*ST_PACKAGE_VERSION)', 'return "%s"\\1' % version, src)
if out == src:
sys.exit("ERROR: ST_PACKAGE_VERSION marker not found in autorun.brs — refusing to ship an "
"unstamped package, which would loop on every update check.")
open(path, 'w').write(out)
PY
echo " stamped package version $VERSION"
fi
# Point a batch at a specific server without hand-editing each card.
if [ -n "$SERVER" ]; then
python3 - "$STAGE/screentinker.json" "$SERVER" <<'PY'
import json, sys
path, server = sys.argv[1], sys.argv[2]
cfg = json.load(open(path))
cfg['server_url'] = server
json.dump(cfg, open(path, 'w'), indent=2)
PY
echo " server_url set to $SERVER"
fi
mkdir -p "$(dirname "$OUT")"
rm -f "$OUT"
ABS_OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
# -0 = STORED, no compression. This is not a size/speed preference, it is a compatibility
# requirement: BrightSign's automated deployment reported our first archive as invalid and could
# not open it. The player bootstrap extracts autozip.brs by itself, before any script runs, and
# roBrightPackage documents a specific set of supported methods — "no compression" is the one that
# is universally safe. A deflated archive copies onto the player perfectly and then fails to open,
# which looks like a broken deployment rather than a broken zip.
#
# -X drops extra attributes; -j would flatten any directories added later, so instead cd in and zip
# '.' so the archive root IS the staging root and future subdirectories keep their structure.
( cd "$STAGE" && zip -q -r -X -0 "$ABS_OUT" . )
echo " built $OUT"
unzip -l "$OUT" | sed 's/^/ /'
# Prove the root-level invariant rather than trusting it: this is the one mistake that makes a
# card look blank to the player, and it is invisible until hardware refuses to boot.
if unzip -l "$OUT" | awk 'NR>3 && $4 ~ /\// && $4 !~ /^[^\/]+$/ {print $4}' | grep -qE '^[^/]+/'; then
echo " NOTE: archive contains directories — verify they are intended subdirectories, not a wrapper."
fi
if ! unzip -l "$OUT" | grep -qE ' autorun\.brs$'; then
echo "ERROR: autorun.brs is not at the archive root — the player would never find it." >&2
exit 1
fi
if ! unzip -l "$OUT" | grep -qE ' autozip\.brs$'; then
echo "ERROR: autozip.brs is missing — nothing would unpack this archive." >&2
exit 1
fi
# Prove every entry is STORED. A single deflated member is enough to make the archive unopenable
# on the player, and it is invisible until a deployment fails in the field.
if unzip -v "$OUT" | awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[A-Za-z]/ && $2 != "Stored" {print $2}' | grep -q .; then
echo "ERROR: archive contains compressed members; BrightSign needs it stored (zip -0)." >&2
unzip -v "$OUT" | sed 's/^/ /' >&2
exit 1
fi
echo " root-level layout verified, all members stored"

View file

@ -80,8 +80,28 @@ sed -i -E "s/(versionCode.*\?:[[:space:]]*)\"[0-9]+\"/\1\"$((CODE + 1))\"/" andr
NUMERIC="${NEW%%-*}"
sed -i -E "/^<\?xml/! s/([[:space:]]version=\")[0-9][^\"]*(\")/\1${NUMERIC}\2/" tizen/config.xml
# 5) commit + annotated tag (no push)
git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml
# 5) public API spec version. This is the number Redoc prints at the top of the published
# API reference (frontend/api-docs.html renders docs/openapi.yaml directly), so leaving it
# behind means customers read a version that has not existed for months — it had drifted to
# 1.9.0 while shipping 1.9.25 precisely because this step did not exist. Anchored to the
# two-space ` version:` under `info:`; operation-level and schema-level keys are indented
# deeper and are not touched. As with Tizen, use the numeric form: the spec version is a
# published API identity, not a build label.
sed -i -E "0,/^ version:/s/^( version:[[:space:]]*).*/\1${NUMERIC}/" docs/openapi.yaml
# 6) CHANGELOG guard. Deliberately NOT auto-generated — a generated changelog reads like
# documentation while saying nothing, and the entry has to come from whoever knows what
# shipped. This only refuses to let a release be cut silently without one, which is how the
# file fell 23 versions behind.
if ! grep -q "^## ${NEW}$" CHANGELOG.md 2>/dev/null; then
echo
echo " WARNING: CHANGELOG.md has no '## $NEW' entry."
echo " Add one before pushing the tag — the release notes are read from it."
echo
fi
# 7) commit + annotated tag (no push)
git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml docs/openapi.yaml
git commit -q -m "chore(release): v$NEW"
git tag -a "v$NEW" -m "ScreenTinker v$NEW"

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