Compare commits

..

59 commits

Author SHA1 Message Date
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
54 changed files with 4632 additions and 218 deletions

View file

@ -109,6 +109,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"
@ -131,6 +137,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

@ -100,15 +100,42 @@ jobs:
- name: Generate release notes
run: |
PREV="${{ steps.ver.outputs.prev }}"
VERSION="${{ steps.ver.outputs.version }}"
# Prefer the hand-written CHANGELOG section for this version.
#
# The generated list is commit SUBJECTS, which describe the work, not the release: cutting
# 1.9.34 produced a page reading "chore(release): v1.9.34" and one changelog commit, while
# the entry describing single sign-on, the native-dependency removal, the update fixes and
# every outside contributor sat in CHANGELOG.md and was never published. The notes on the
# release page are what most people actually read, so they should be the written ones.
#
# awk rather than sed: the body contains regex metacharacters and markdown that a sed range
# would mangle. This takes everything between `## <version>` and the next `## ` heading.
CHANGELOG_BODY="$(awk -v v="## $VERSION" '
$0 == v {found=1; next}
found && /^## / {exit}
found {print}
' CHANGELOG.md)"
{
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
echo "$CHANGELOG_BODY"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
# No entry for this version — fall back to commit subjects rather than publish a
# release with no notes at all. scripts/bump-version.sh already warns when the
# CHANGELOG has no matching heading; this is the same gap showing up downstream.
echo "_No CHANGELOG entry for $VERSION; listing commits instead._"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
fi
fi
echo
echo "### Artifacts"

View file

@ -1,115 +1,392 @@
# Changelog
## 1.9.34-alpha2
## 1.9.36
Everything in `1.9.34-alpha1`, plus one fix without which the headline feature could not be used with
Microsoft at all.
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
### Fixed — Microsoft sign-in could never complete
The login callback required the identity provider to assert `email_verified: true`. **Entra ID v2
does not send that claim**, so a Microsoft login would authenticate correctly against the tenant and
then be refused on the way back with `email_unverified`. Found while configuring a real Entra
application, before a single sign-in was attempted; the SSO tests checked how the Microsoft issuer
string is built but never put a Microsoft-shaped token through the policy.
### Fixed — 1.9.35 would not start on a server collecting install statistics
The strict check was itself a fix — an earlier version accepted an omitted claim — and it stays
exactly as strict for a provider a **customer** configures, because such a provider is chosen by the
party it vouches for and its bare assertion is worth nothing. What changed is recognising this as a
question about *who was trusted* rather than about what the token contained: an instance-wide
provider is chosen by the operator, and a Microsoft entry is pinned to a single tenant GUID, so only
that directory can issue a token this server will accept.
A server with install-statistics collection switched on could not start 1.9.35. It threw
`ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
and a service manager configured to restart it would do so in a loop.
An explicit `email_verified: false` is still refused from anyone, and an organization's own provider
can never make the assumption. Google is unchanged — it does send the claim.
**Almost nobody is affected.** The fault is inside a block that only runs when a server is configured
to *collect* statistics from other installs — not when it merely reports its own. That is a single
deployment, not a normal install. If you have never set `TELEMETRY_COLLECTOR`, 1.9.35 runs correctly
and this release changes nothing for you.
Other identity providers that verify addresses without saying so in the token can opt in with
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
The cause was a reference to the database resolved when the file loaded rather than when the request
arrived, in code that had been moved earlier in the same release.
### Fixed — documentation that would have cost you an afternoon
`MICROSOFT_CLIENT_SECRET` is read by the server but was missing from the README table. The redirect
URI must be registered under Entra's **Web** platform, not **SPA** — a SPA registration is rejected
at the token endpoint, because this exchange runs server-side and sends no browser `Origin`. And the
`email` optional claim has to be added under *Token configuration → ID*, or the token arrives with no
address at all.
### Changed — the startup check now covers configuration only one deployment uses
## 1.9.34-alpha1
The fault above shipped through a full test suite and every CI job green, because the affected block
is switched on by configuration that no test set. It had never executed anywhere except the one
server that turns it on.
**A prerelease, for the alpha instance.** It is not a production build and no display should be
pulled onto it except deliberately: a prerelease sorts *below* its own release in semver, so a player
that takes `1.9.34-alpha1` without being opted in will see `1.9.33` as newer and roll itself back.
Opting a display in is what stops that.
The startup smoke check now boots with that configuration enabled and confirms the routes it adds
actually answer. Code that only one deployment runs is exactly the code an automated check has to
exercise, and it now does.
The headline is single sign-on, rebuilt from nothing — and the reason it was rebuilt rather than
extended is a vulnerability in what was there before.
### Upgrading
No migrations, no configuration changes, and no dependency changes from 1.9.35 — this release only
alters when one value is read. Upgrading from 1.9.34 or earlier, the 1.9.35 note still applies:
`npm ci --omit=dev` is required in both directions, which `scripts/upgrade.sh` already runs.
## 1.9.35
A maintenance release. Two faults where the product was working correctly and still looked broken to
whoever was standing in front of the screen, plus the dependency advisories that could reach a running
server.
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### Fixed — a player could get stuck on an update it was never able to install
A staged update is saved under a filename built from the version the server advertised. If a server
advertised one version while still serving the file for an older one, the player saved the old file
under the new name — and from then on found it, verified its signature, accepted it, and installed
something that changed nothing. The version never moved, so the same update was offered again, and the
player retried the same no-op until it hit its attempt limit.
The signature check passed the whole time, correctly: the file was genuine, it was simply the wrong
one. Worse, fixing the server did not help, because the bad file was reused before anything was
downloaded. Recovery meant deleting the file on the device by hand.
A staged update is now reused only when the version inside the file matches the version being
installed, and a fresh download is checked the same way before it is applied. A server serving the
wrong file now says so — *"server served 1.9.33 but advertised 1.9.34 — the update on the server is
stale"* — and the file is deleted instead of kept. That makes this self-healing: once the server is
corrected, the player recovers on its own.
**Clear update cache** on the device page discards every staged update on a player. The version check
should make it rarely necessary; it exists because a player already holding a bad file predates this
release and cannot benefit from the check, and because the alternative is a cable and a laptop.
### Fixed — directory search showed the system keyboard on top of its own
The directory-search widget draws its own on-screen keyboard, sized and themed to the panel and on by
default. On Android it was never visible. The page puts the cursor in a real text field, which is the
signal for the device to raise its system keyboard — over the bottom of the screen, exactly where the
widget's keyboard is.
So a wall-mounted directory showed the phone keyboard: split across the screen, with microphone, GIF,
emoji and a settings key that opens the keyboard vendor's own interface on a kiosk. On one panel the
only keyboard installed was voice input, so touching the search box opened a microphone. The widget's
own keyboard had been underneath the whole time.
When the widget draws a keyboard, it now tells the device not to raise one. Turn the built-in keyboard
off and the system keyboard behaves as before — with nothing to cover, it is the only way left to type.
### Changed — the dependency advisories that could reach a running server are cleared
Every high-severity advisory affecting a production install is resolved, including eight in the mail
library covering SMTP command injection and header injection. The remaining advisories are in
development-only tooling that is not installed on a server and cannot be reached from one.
The real-time connection to players is deliberately untouched: the fix there was a patch to the message
parser with no change to the format players speak, so nothing about an existing player's connection
changes.
Sending mail was previously covered only by tests that substituted the mail library for a stand-in,
which would have stayed green through any change in the library itself. It is now also tested against
the real one.
### Added — an install that collects statistics can show the total on its landing page
Where install statistics are being collected, the landing page can show how many screens have been
deployed in total. It is an aggregate across every install that chooses to report, so it says nothing
about any single one.
This does nothing on a normal install: the figure is served only where collection is switched on, so a
private server never publishes its own screen count, and the line is hidden entirely rather than
showing a zero.
### Changed — release notes are the written ones
Published release notes now come from this file rather than from a list of commit subjects. The
previous release announced itself as one commit titled "chore(release)" while the entry describing it
sat here unread.
### ⚠️ Upgrading from 1.9.34 reinstalls dependencies
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in
both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
startup where it can reach the npm registry.
Docker deployments need no action; dependencies are installed inside the image.
## 1.9.34
Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what
was there before. Alongside it: the last native image dependency is gone, several players that
could not install updates now can, and an install can optionally report how many screens it runs.
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### Fixed — the old sign-in path could be replayed by any site you had signed into
What shipped as "OAuth" verified almost nothing. The Google path asked `tokeninfo` whether an
**access** token was valid and then trusted the email address in the reply. The Microsoft path handed
a bearer token to Graph `/me` and trusted that. Neither asked the only question that matters: *who
was this token issued for?*
What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and
then trusted the email address that came back, never asking the only question that matters: *who was
this token issued for?* Any other site a user had signed into — anything holding a token with the
right scope — could replay it against ScreenTinker and receive a session as that user. No password,
no interaction from the victim.
So any other site a user had signed into — anything that had requested `email` or `User.Read` — held
a token it could replay against ScreenTinker and receive a session as that user. No password, no
interaction from the victim.
Identity now comes from an **ID token only**, with the signature checked against the provider's
keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider:
Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries
rather than hand-written special cases, which is what removed the two paths that were wrong.
Identity now comes from an **ID token only**, with signature checked against the provider's JWKS and
`iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider: Authorization Code
with PKCE, completed server-side. Google and Microsoft became ordinary entries rather than special
cases, which is what removed the two hand-written paths that were wrong.
### Added — organizations bring their own single sign-on
Instance-wide providers stay the default and are now unlimited in number. On top of that, an
organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking
OpenID Connect — configured by that organization's own admins in Settings, with no operator
involvement and no restart.
### Added — organizations bring their own identity provider
Instance-wide providers stay the default and are now unlimited in number. On top of that an
organization may configure its own provider, but only for domains it has **proved it controls** — a
A provider may only assert addresses at domains the organization has **proved it controls**, via a
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
releases the domain, so a typo cannot park someone else's domain indefinitely.
releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to
one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard
zone we do not operate, and it would turn a subdomain takeover into an apex takeover.
Proof by delegated name (CNAME) is refused outright. It would have required a wildcard zone we do not
operate, and worse, it would turn a subdomain takeover into an apex takeover.
An organization's provider never appears publicly. The login page reveals it only after someone
enters an address at a verified domain, so a guessed domain cannot confirm who your customers are.
**SSO-only** is available per organization: passwords refused, other providers refused, the instance
Google button refused. Turning it *off* again needs a platform admin to approve the request, so one
compromised org admin cannot quietly reopen password login. Break-glass for a platform admin is the
correct password and nothing else — and a wrong password returns the same 403 everyone else gets, so
it cannot be used to discover whether an account exists.
**Require single sign-on** is available per organization: passwords refused, other providers
refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would
just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the
request, so one compromised org admin cannot quietly reopen password login. Break-glass for a
platform administrator is the correct password and nothing else, and a wrong password returns the
same refusal everyone else gets, so it cannot be used to discover whether an account exists.
With no SSO environment variables set, the product behaves exactly as it did before. That was
verified in a browser, not merely reasoned about.
⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible
without a reset.
Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other
grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator,
and an organization's own provider is believed once it has verified a domain — the DNS proof stands
in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has
verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone.
Other providers that verify addresses without saying so can opt in with
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
**With no SSO environment variables set, the product behaves exactly as it did before.**
### Added — an existing account can move to single sign-on
Signing in with a provider has always refused to take over an account that already has a password,
and that refusal is right — otherwise anyone who could persuade a provider to assert your address
would inherit your account. But the way out had never been built, so an account created with a
password simply could not use single sign-on.
**Settings → Sign-in method** now offers it, in both directions. An account has exactly **one**
credential: linking **deletes** the password, and the confirmation says so, because a password left
behind is a second way in that you believe you replaced. Unlinking asks for the new password first
and applies both changes together, so the account is never left without a way in.
The account being linked is the one you are **signed in as**, never whichever account matches the
address the provider returns — that is what separates linking from the takeover the login page
refuses. Only providers configured on this server can be linked; an organization's own provider
cannot attach itself to an account.
### Changed — the login page asks who you are before how you sign in
The password box appears once you have entered your address and continued, rather than sitting there
from the start. That is what lets the page check whether your organization uses single sign-on
*before* offering you a credential, so someone whose company requires it is shown that rather than a
password box that was always going to be refused. Correcting your address takes you back a step.
The address is no longer looked up on every keystroke — it answered for half-finished domains,
changed the form under you mid-address, and could exhaust a shared office network's lookup budget
before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so
the page no longer changes shape while you type.
Setup instructions for both operators and organization admins are in
[docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications
— including the one that catches everyone: the Microsoft tenant setting names the directory that
*authenticates the user*, which for personal accounts is not the directory the application is
registered in.
### Changed — image processing no longer needs a native library
Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and
avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and
`better-sqlite3` is the only native module left.
A native module needs a prebuilt binary matching both the platform and the Node version; when there
isn't one the server fails at load with an error that reads like database corruption rather than a
missing image library. That class of failure is gone from this half of the product.
Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif
through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library
in use decodes AV1 but refuses HEVC.
Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a
12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks
an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats
and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with
serving requests.
### Fixed — players that could not install an update
Three separate faults, each able to strand a player on an old version.
**Updates were written to external storage.** Where that location is absent, or exists but cannot be
written to, the download failed the instant it began — before any data arrived — and reported only
that it had failed to download or verify. The same player could be caching content perfectly well
throughout, because content goes to internal storage. Updates now go to the first location that
genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to
it* rather than by asking whether it is writable — the previous check asked, was told yes, and the
write failed anyway.
**Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one
numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved
forward, while the server named the newer build as latest in the same reply. Numbers in version
names are now compared as numbers. The BrightSign host package carried the same comparison and is
fixed with it — there, a wrong answer replaces the script that starts the player.
**A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate
comes from a legacy path that can return nothing. The player now reads the signature itself before
giving up. Verification is unchanged: the certificate is still compared against the installed app,
and anything unsigned, tampered with, or signed by a different key is still rejected.
A failed update now also says which of those things went wrong, instead of one message covering
every possible cause.
⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how
updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by
hand, after which it recovers on its own and stays fixed.
### Fixed — the Android player could leave a band down one edge of the screen
A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar.
It was intermittent because it depended on whether the app was measured before or after the system
UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart.
The stage is now measured from the current window and re-measured when focus changes.
Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem;
pinning the output resolution fixed the corruption, and this fixes the band that remained.
### Added — opt-in install statistics
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
design and should stay that way. A platform administrator is asked, once, whether this install will
share how many screens it runs.
The whole payload is three fields — a random instance ID, the version, and the screen count — and
nothing else: no hostnames, addresses, organization or user names, device names, content or
configuration. Settings shows the **actual payload this server would send**, generated live from its
own data, alongside what it last really sent and when, so the claim can be checked rather than taken
on trust. Turning it on reports immediately, and a blocked outbound connection is named along with
the address to allow, rather than failing silently.
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you
run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who
wants their own numbers and nothing sent to us can set it and leave sharing off.
The random ID exists only so repeat reports from one server count as one server, which makes a
report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in,
any total published from it is a floor, never an estimate of the install base. Full detail in
[docs/telemetry.md](docs/telemetry.md).
### Added — organizations may re-enable same-origin widgets, deliberately
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce strict
CORS. There is now an org-level switch to put it back, behind a modal that requires the operator to
type an acknowledgement, with a persistent banner while it is on. Enabling it needs an organization
owner or admin — a workspace admin is deliberately not enough — and the change is written to the
activity log. Contributed by @ChrisChrome.
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce
strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a
typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or
admin — a workspace admin is deliberately not enough — and the change is written to the activity
log. Contributed by @ChrisChrome.
The **widget editor's Preview is excluded** from that switch. Preview renders inside the dashboard,
where the admin's session token lives, so honouring the setting there would have let anyone who can
author a widget lift the session of whichever admin clicked Preview. The setting exists so *displays*
can embed origin-strict sites; a display holds a device token, an admin's browser does not.
The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard
where the admin's session token lives, so honouring the setting there would let anyone who can
author a widget lift the session of whichever admin clicked Preview. The setting exists so
*displays* can embed origin-strict sites; a display holds a device token, an admin's browser does
not.
### Fixed — RSS tickers ran at a speed that depended on how much news there was
`scroll_speed` was wired straight into `animation-duration`, so it set a fixed total time for the
whole strip to cross the screen regardless of length. A feed with twenty items was dragged past in
the same seconds as a feed with one — too fast to read, and it appeared to jump back to the start.
It now calibrates a constant pixels-per-second rate, so more items simply take proportionally longer
and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so
a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read,
and it appeared to jump back to the start. It now holds a constant rate, so more items simply take
proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
### Fixed — user-controlled text is escaped where it actually reaches HTML
### Fixed — user-controlled text is escaped where it reaches the page
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
Also in this release: dashboard banners no longer overlap the sidebar, shift the layout or vanish
when switching views, and the main content no longer collapses to a narrow column.
Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when
switching views, and the main content no longer collapses to a narrow column.
### Known limitations in this alpha
Deliberately not resolved yet, and worth knowing before testing against them:
### Added — an operations runbook
[docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both
shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only
obvious once they have bitten you — including three from a Raspberry Pi 5 report, two of which are
not Pi-specific. A piped installer cannot really ask you anything, because the pipe is its input and
every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor
hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD
card by discarding writes — safe for a player, quietly destructive for a server whose database is
written continuously.
- Enabling SSO-only **clears the passwords** of members at verified domains. That is irreversible
### Changed — `better-sqlite3` pinned to 12.9.0
Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database
driver can move independently rather than as one flag day.
The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both
the current and the next Node major; later 12.x releases dropped the older one while still
advertising support for it. A caret range would resolve to one of those and silently turn
installation into a source build. Nothing in the query API changed.
### ⚠️ Upgrading from 1.9.33 reinstalls dependencies
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** —
in both directions.
- **Upgrading**: `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
startup where it can reach the npm registry.
- **Rolling back past this release**: mandatory. Earlier builds load a native image library at
runtime that this release removes, so rolling back the code without reinstalling leaves a server
whose image ingest cannot load its decoder.
Docker deployments need no action either way; dependencies are installed inside the image.
### Known limitations
Deliberately unresolved, and worth knowing:
- Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly
without a reset.
- The SSO-only removal queue is an availability dependency on the operator: if nobody approves, the
organization stays SSO-only.
- Turning that requirement back off depends on a platform administrator approving the request; if
nobody does, the organization stays on single sign-on.
- `landing.html` still interpolates plan names into HTML without escaping. Those values come from
the plans table rather than from end users, so it is a loose end rather than an exposure.
- `/api/provision` is limited to 5/min, so a twenty-display install day takes four minutes of waiting.
Pre-existing, unchanged by this release.
- `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves
some waiting. Pre-existing and unchanged by this release.
### Thanks
This release — and a good deal of what came before it — exists because people outside the project
reported problems and sent patches. Credit was recorded inconsistently at the time, so it is
collected here rather than left scattered.
**Code contributed**
- **@ChrisChrome** — the organization-level widget sandbox toggle (#254) and the RSS ticker rate fix,
both in this release. Earlier: the Debian player/server install script (#137) and web player
auto-connect (#6).
- **@BlazzzPlay** — eight merged pull requests across 1.9.4 to 1.9.13: server-side preview sessions
to work around CSP (#151), the Android hidden settings menu (#152), sending device identity on
reconnect before pairing (#164), the dashboard version indicator and update check (#165, #181),
authenticated thumbnail loading (#182), the server URL in the Add Display modal and the Releases
link on the APK download page (#210), and uploads respecting the current folder (#211).
- **@a10kiloham** — boot-time thumbnail healing with ffmpeg diagnostics and packaging (#244), the
screenshot-request verdict toast and the reverse-proxy header pitfall it documented (#243), and a
configurable maximum upload size (#233).
- **@albanobattistella** — the Italian translation, and its updates since (#2, #145, #232).
**Reported**
- **@carloblu74** — the Raspberry Pi 5 report behind #245, which found five defects in the installer
and kiosk launcher that nothing in this repository would have caught, because nothing here had ever
executed those scripts on a Pi. The runbook notes above come from it.
- **@bold-media-group** — by a wide margin the largest source of field reports, across roughly fifty
issues: the OTA rollout and version-advertising problems, event-loop lag under long uptime, video
wall behaviour, Tizen playback regressions, and the content-loading failures that led to resumable
downloads.
- **@Smiley-k**, **@Semetra22**, **@patrickfinardi09**, **@hapishyguy**, **@Nikhil12656**,
**@gittyguy92** and **@Obe-BoldMediaGroup** — bug reports and feature requests across the 1.9.x
line, including SMTP transport, playlist item scheduling, and the Android playlist-order fault
behind #234.
Several of the hardest faults this year were found by someone running the product on hardware the
project does not own. That is worth saying plainly.
## 1.9.33

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

View file

@ -320,6 +320,10 @@ 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.
@ -425,6 +429,13 @@ 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
@ -619,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:

View file

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

View file

@ -149,6 +149,27 @@ class MainActivity : AppCompatActivity() {
// Fullscreen immersive
@Suppress("DEPRECATION")
/*
* Ask for a full-bleed window through BOTH APIs.
*
* systemUiVisibility has been deprecated since API 30 and some OEM builds honour it only
* partially: `dumpsys window` on one RK356x box reported `init=1920x1080 app=1920x1024`,
* i.e. the firmware kept reserving 56px for a navigation bar that was set to hide, so the
* app was never given those pixels to paint. WindowCompat is the supported route on those
* builds. Both are set because neither is reliable alone across signage hardware: the
* legacy flags still carry older devices, the compat API carries newer and OEM ones.
*
* Verify per device rather than assume `adb shell dumpsys window displays` should show
* app= equal to init=. If it still does not, the reservation is a firmware behaviour no
* app-side call can override and it has to be turned off on the device.
*/
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
androidx.core.view.WindowInsetsControllerCompat(window, window.decorView).apply {
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
systemBarsBehavior =
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
@ -370,12 +391,57 @@ class MainActivity : AppCompatActivity() {
// (playerView/imageView/youtubeWebView) and multi-zone (ZoneManager renders into
// the same rootView). Values mirror the dashboard: landscape / portrait /
// landscape-flipped / portrait-flipped.
private fun applyOrientation(orientation: String) {
if (orientation == currentOrientation) return
currentOrientation = orientation
/**
* The size of the WINDOW we are allowed to paint, measured NOW.
*
* Deliberately the window and not the display. On a box whose firmware keeps reserving space
* for a system bar, `dumpsys window` reports e.g. `init=1920x1080 app=1920x1024`: the panel is
* 1080 tall but the window is 56px shorter, and those 56px are simply not ours to draw in.
* Sizing the stage to the DISPLAY there would not fill the gap it would push the bottom of
* every asset outside the window and silently crop it, which is worse than a border.
*
* The original defect was not which size was read but WHEN: `resources.displayMetrics` was read
* once, while a bar was still on screen, and written into rootView's layoutParams for good.
* Immersive mode is a request bars hide and the window grows a few frames later so a
* playlist arriving first froze the stage at bar-sized dimensions, leaving dead space exactly
* the size of a bar that had since vanished. It looked random because it was a race, and
* rendering the cached playlist immediately at boot made losing it the common case.
*
* So: read it late, read it again whenever the window changes, and never cache it across a
* window resize. See reapplyOrientation().
*/
private fun windowSize(): Pair<Float, Float> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val b = windowManager.currentWindowMetrics.bounds
return b.width().toFloat() to b.height().toFloat()
}
val m = resources.displayMetrics
val w = m.widthPixels.toFloat()
val h = m.heightPixels.toFloat()
return m.widthPixels.toFloat() to m.heightPixels.toFloat()
}
/** Stage size last applied, so a stage sized during a transient window state can heal. */
private var appliedStageW = 0f
private var appliedStageH = 0f
/**
* Re-run the current orientation against the panel size as it is NOW.
*
* Called when the window settles (focus regained, bars finally hidden). Without this, a stage
* measured too early is permanent: applyOrientation() returns immediately when the orientation
* string has not changed, and it never changes on a display that has always been landscape.
*/
private fun reapplyOrientation() {
applyOrientation(currentOrientation ?: "landscape")
}
private fun applyOrientation(orientation: String) {
val (w, h) = windowSize()
// The guard compares the measured SIZE as well as the orientation. Comparing the string
// alone is what made a bad measurement unrecoverable.
if (orientation == currentOrientation && w == appliedStageW && h == appliedStageH) return
currentOrientation = orientation
appliedStageW = w
appliedStageH = h
val (rot, swap) = when (orientation) {
"portrait" -> 90f to true
"portrait-flipped" -> 270f to true
@ -875,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) {
@ -1511,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

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

View file

@ -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

@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: ScreenTinker Public API
version: 1.9.34
version: 1.9.36
description: |
Public, token-scoped REST API for ScreenTinker digital signage.

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.

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

@ -210,6 +210,12 @@ export const api = {
// TOTP 2FA (#100) — opt-in per-user, local accounts only. See routes/auth.js.
totpStatus: () => request('/auth/totp/status'),
// Unlink an instance-wide SSO provider. The new password is required in the same call:
// the account must never sit between credentials.
ssoUnlink: (password) => request('/auth/oidc/unlink', { method: 'POST', body: JSON.stringify({ password }) }),
// Returns { url } to navigate to. Fetched rather than navigated to, because the session is
// a bearer token and a top-level navigation cannot carry one.
ssoLinkStart: (slug) => request(`/auth/oidc/${encodeURIComponent(slug)}/link/start`),
totpSetup: () => request('/auth/totp/setup', { method: 'POST' }),
totpEnable: (code) => request('/auth/totp/enable', { method: 'POST', body: JSON.stringify({ code }) }),
totpDisable: (code) => request('/auth/totp/disable', { method: 'POST', body: JSON.stringify({ code }) }),
@ -260,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

@ -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',
@ -681,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',
@ -771,6 +775,7 @@ export default {
'device.toast.screen_on_sent': 'Screen on command sent',
'device.toast.launch_sent': 'Launch command sent',
'device.toast.update_triggered': 'Update check triggered',
'device.toast.update_cache_cleared': 'Update cache cleared — the next check will download afresh',
'device.toast.remote_started': 'Remote session started',
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
@ -836,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',

View file

@ -1,7 +1,7 @@
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';
@ -290,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">
@ -432,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

View file

@ -501,6 +501,12 @@ async function loadDevice(deviceId, activeTab = null) {
<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">
@ -1593,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();

View file

@ -287,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';
@ -304,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;
@ -509,7 +551,6 @@ function setupHandlers(config, isSetup) {
* 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 ssoLookupTimer = null;
let lastDomainAsked = '';
const orgSlot = () => document.getElementById('orgSsoSlot');
@ -520,44 +561,68 @@ function setupHandlers(config, isSetup) {
* 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.
*/
function setPasswordVisible(visible) {
/*
* 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 and left
* a login page whose only action was "Create Account". Found by looking at a screenshot.
* that same group, so hiding the container took the single sign-on button down with it.
*/
const show = visible ? '' : 'none';
for (const id of ['loginPassword', 'loginPasswordLabel', 'loginBtn']) {
for (const id of ['loginPassword', 'loginPasswordLabel']) {
const el = document.getElementById(id);
if (el) el.style.display = show;
}
/*
* The instance's own providers go too. They are the operator's, not this organization's, and
* they are not domain-confined so offering "Continue with Google" to someone whose company
* requires its own identity provider is offering them the bypass. The server refuses it either
* way; this stops the page inviting it.
* 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 instance = document.getElementById('instanceProviders');
if (instance) instance.style.display = show;
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' : '';
/*
* "Create Account" goes too. Registration at an SSO-only domain is refused by the server, and
* leaving the button was worse than useless: it was the ONLY action left on the card, so the
* page invited the one thing that cannot work.
* 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 = show;
// The OR divider sits outside #instanceProviders, so hiding those alone left a dangling rule
// with nothing beneath it.
const divider = document.getElementById('ssoDivider');
if (divider) divider.style.display = show;
// "Forgot your password?" sits in its own <p>; hide the wrapper so no empty gap is left.
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 = show;
wrap.style.display = ssoOnlyDomain ? 'none' : '';
}
}
// 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();
@ -650,11 +715,21 @@ function setupHandlers(config, isSetup) {
}
}
document.getElementById('loginEmail')?.addEventListener('input', (e) => {
clearTimeout(ssoLookupTimer);
const value = e.target.value;
ssoLookupTimer = setTimeout(() => lookupOrgSso(value), 400);
});
/*
* 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.

View file

@ -62,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>
@ -159,6 +169,13 @@ export async function render(container) {
<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">
@ -265,6 +282,7 @@ export async function render(container) {
if (isAdmin) {
loadUsers();
loadWhiteLabel();
loadTelemetry();
// Support token generator
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
@ -526,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;
@ -660,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');

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

@ -641,6 +641,19 @@ const migrations = [
// it can do nothing and must be respected.
'ALTER TABLE devices ADD COLUMN capabilities TEXT',
// Opt-in install statistics, COLLECTOR side only — inert unless TELEMETRY_COLLECTOR=1, which
// is the hosted deployment. Keyed by instance_id and upserted rather than appended, so it is a
// table of current state ("this install last reported N screens") rather than an event log that
// grows without bound on a box nobody prunes. Answering "how many screens are deployed" needs
// the latest row per install, never the history.
`CREATE TABLE IF NOT EXISTS telemetry_reports (
instance_id TEXT PRIMARY KEY,
version TEXT,
screen_count INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
)`,
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.

View file

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

View file

@ -31,7 +31,7 @@ function safeFilename(name) {
* orientation bug (#170) that the ingest path fixes with imageDisplayDims + .rotate(). A second
* copy of this logic is a second place for it to rot; there is now one.
*
* Best-effort by contract: a missing ffprobe or a sharp failure yields nulls and a warning, never
* Best-effort by contract: a missing ffprobe or a decode failure yields nulls and a warning, never
* a throw the file itself is already stored and is worth more than its metadata.
*
* @returns {{width:number|null, height:number|null, durationSec:number|null, thumbnailPath:string|null}}
@ -39,25 +39,30 @@ function safeFilename(name) {
async function deriveMediaMetadata(sourcePath, filepath, mime) {
let width = null, height = null, durationSec = null, thumbnailPath = null;
try {
// SVG is deliberately NOT handed to sharp: rasterising it goes through librsvg, which
// is where the outstanding libvips CVEs live, and an SVG is already its own thumbnail.
// SVG is deliberately NOT rasterised: it is already its own thumbnail. (It also used to be
// the one format kept away from sharp, because rasterising went through librsvg — where the
// outstanding libvips CVEs live. Nothing rasterises it now either.)
if (mime === 'image/svg+xml') {
thumbnailPath = filepath;
} else if (mime.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(sourcePath).metadata();
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape.
({ width, height } = imageDisplayDims(metadata));
// Assign thumbnailPath only AFTER the write succeeds: a sharp failure used to
// return the already-assigned name for a file that was never written, storing a
// phantom thumbnail_path that the UI then requests forever as a broken image.
const imageOps = require('./image-ops');
const thumbName = `thumb_${filepath}`;
await sharp(sourcePath)
.rotate() // #170: auto-orient per EXIF (and strip the tag) so the thumbnail matches
.resize(config.thumbnailWidth)
.jpeg({ quality: 70 })
.toFile(path.join(config.contentDir, thumbName));
thumbnailPath = thumbName;
// Measure and thumbnail from ONE decode. Asking separately costs two, and a decode is the
// single most expensive thing on this path (~1s for a 12MP photo — unlike sharp, whose
// .metadata() only read the header). #170: rotation is implicit, the decoder auto-orients,
// so the recorded dimensions and the thumbnail agree without an explicit rotate.
const metadata = await imageOps.measureAndThumbnail(
sourcePath, path.join(config.contentDir, thumbName), config.thumbnailWidth, 70);
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape. The decoder
// applies it and reports orientation 1, so this is a no-op pass-through today — kept so the
// rule lives in one place regardless of which decoder is underneath.
({ width, height } = imageDisplayDims(metadata));
// Assign thumbnailPath only if the write actually succeeded: naming it unconditionally used
// to store a phantom thumbnail_path for a file that was never created, which the UI then
// requests forever as a broken image. The dimensions above survive that failure on purpose —
// they are independently useful, and losing them would letterbox the asset wrongly.
if (metadata.thumbnailWritten) thumbnailPath = thumbName;
else console.warn(`Thumbnail write failed for ${filepath}: ${metadata.thumbnailError}`);
} else if (mime.startsWith('video/')) {
try {
// execFile, NOT execFileSync. These two spawns each carry a 15s timeout, and run

View file

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

View file

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

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

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

View file

@ -74,11 +74,18 @@ function fromEnv(env, slug) {
* already exempts it from domain confinement and the Microsoft entry is additionally pinned to one
* tenant GUID, so only that directory can issue tokens for it.
*
* Two limits keep this from becoming the hole the strict check was closing:
* An organization's own provider may assume too, but only once it has DNS-verified a domain the
* callback confines it to those domains, so it can only ever speak for names it proved it controls.
* Requiring the claim from it as well meant a customer's Entra tenant went green on domain
* verification and then failed the login anyway.
*
* Three limits keep this from becoming the hole the strict check was closing:
* - an EXPLICIT `email_verified: false` is always refused. Assuming only ever covers an omitted
* claim, never a provider actively saying the address is unverified;
* - org-configured providers can never set it (rowToProvider pins it false, and nothing reads it
* from the database), so the account-takeover path stays shut.
* - for an org provider it is DERIVED from proof (`verified.length > 0`) and is never a column, so
* a customer cannot switch it on for themselves;
* - domain confinement is unchanged and still runs first, so an org provider that assumes still
* cannot assert an address outside a domain it has proven.
*/
function emailIsVerified(claims, provider) {
const asserted = (claims || {}).email_verified;
@ -211,6 +218,9 @@ function db() {
}
function rowToProvider(row, secretbox) {
// Resolved once: it decides both which addresses this provider may assert and, below, whether it
// has proven anything at all.
const verified = verifiedDomainsFor(row.id);
return {
slug: row.slug,
name: row.name,
@ -226,14 +236,26 @@ function rowToProvider(row, secretbox) {
: null,
scopes: row.scopes || DEFAULT_SCOPES,
/*
* NEVER settable for an organization's provider, and deliberately not read from the row.
* DERIVED from proof, never read from a column.
*
* A customer chooses this provider, so it speaks for the party it vouches for. Letting an org
* assume verification would hand back exactly the takeover primitive the strict check exists to
* stop. Domain confinement narrows WHICH addresses it may assert; this keeps the assertion
* itself honest.
* Entra ID v2 omits email_verified, so demanding it refused every customer who brought their own
* Microsoft tenant the domain went green and the login still failed. Requiring a claim
* Microsoft does not send is not a security control, it is an outage.
*
* What makes it safe to stop requiring it is the proof that already gates this provider: the
* callback confines it to DNS-verified domains, and an address is only reached here after
* passing that. Whoever controls a domain's DNS controls its mail, which is the same trust that
* makes a verification link meaningful in the first place.
*
* So the assumption is tied to having proven SOMETHING. A provider with no verified domain
* assumes nothing belt and braces, because emailAllowedForProvider already refuses it (an
* empty allow-list matches no domain), and this way a future refactor that reorders those checks
* cannot silently widen it.
*
* Still never a column. An org must not be able to switch this on for itself; it is a
* consequence of DNS proof, not a setting.
*/
assumeEmailVerified: false,
assumeEmailVerified: verified.length > 0,
source: 'org',
organizationId: row.organization_id,
/*
@ -244,7 +266,7 @@ function rowToProvider(row, secretbox) {
* Reading the typed column here would reduce the whole verification feature to a decoration:
* a tenant could type any company's domain and immediately assert addresses in it.
*/
emailDomains: verifiedDomainsFor(row.id).join(','),
emailDomains: verified.join(','),
};
}

View file

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

View file

@ -312,6 +312,9 @@ const COMMAND_CAPABILITY = {
launch: 'system.restart_player',
refresh: 'system.restart_player',
update: 'system.self_update',
// Clearing the staged-APK cache is part of the same self-update surface: a player that can
// update itself is a player that can hold a bad download and needs a way to drop it.
clear_update_cache: 'system.self_update',
// display
screen_on: 'display.power',

View file

@ -14,6 +14,18 @@
* NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes
* every boot fail with NODE_MODULE_VERSION mismatch, which reads like database
* corruption and is not.
*
* better-sqlite3 is pinned to EXACTLY 12.9.0, not a caret range, and the reason
* is invisible from package.json: 12.10.0 DROPPED the prebuilt binary for Node 20
* (ABI 115) while still advertising `"node": "20.x || ..."` in engines. So a caret
* resolves to 12.11.x, finds no prebuild on Node 20, and silently falls through to
* `node-gyp rebuild` a from-source compile during install, and during the repair
* below. That matters here: this file rebuilds synchronously BEFORE the server
* listens, and prod's systemd unit has TimeoutStartSec=90 with Restart=always, so a
* slow or failing compile is a boot loop rather than a self-heal. 12.9.0 is the last
* version shipping prebuilds for BOTH Node 20 (115) and Node 22 (127), which is what
* lets the runtime move without the module having to compile at all.
* Re-check the release assets before widening the pin.
* HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install.
*
* All three present as a server that will not start, with an error that names a file rather than

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

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

View file

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

873
server/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -426,6 +426,50 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
res.json({ enabled });
});
// ===================== Opt-in install statistics =====================
// Returns the decision state, the EXACT payload that would be sent, and what was last actually
// sent. Handing over the real payload rather than a description is the point: an operator can
// check instead of trusting a sentence, and the code is public so a mismatch would be visible.
const telemetry = require('../lib/telemetry');
router.get('/telemetry', requirePlatformAdmin, (req, res) => {
res.json({
state: telemetry.state(), // 'unasked' | 'on' | 'off'
payload: telemetry.payload(db), // what WOULD be sent, right now
endpoint: telemetry.endpoint(), // ours — the host an operator may need to allowlist
extra_endpoint: telemetry.extraEndpoint(),// their own collector, if configured
destinations: telemetry.destinations(), // everywhere it actually goes, right now
last_report: telemetry.getLastReport(), // what was actually sent, and when
last_error: telemetry.getLastError(), // why the last attempt failed, if it did
});
});
router.put('/telemetry', requirePlatformAdmin, async (req, res) => {
// Both answers are recorded. Declining must persist as 'off' rather than staying 'unasked',
// or the prompt returns after every update — which is how telemetry earns its bad name.
const enabled = !!req.body.enabled;
const state = telemetry.setEnabled(enabled);
logActivity(req.user.id, 'admin_set_telemetry', `enabled: ${enabled}`, null, getClientIp(req), null);
// Send once, now, rather than waiting for the next daily tick. Two reasons: the operator is
// standing right here and "nothing has been sent" for the next 24h reads as broken, and an
// egress-filtered network fails HERE where we can name the host to unblock — instead of
// failing silently tonight where nobody is watching.
let first = null;
if (enabled) first = await telemetry.report(db);
res.json({
state,
payload: telemetry.payload(db),
endpoint: telemetry.endpoint(),
extra_endpoint: telemetry.extraEndpoint(),
destinations: telemetry.destinations(),
first_report: first && { sent: first.sent, reason: first.reason || null },
last_report: telemetry.getLastReport(),
last_error: telemetry.getLastError(),
});
});
// ===================== Version update indicator =====================
// check-update = requireAdmin — a read-only GHCR poll, operational.
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the

View file

@ -1158,6 +1158,13 @@ function backToApp(res, params) {
res.redirect(`/app#/login?${qs}`);
}
// A link attempt starts from Settings while signed in, so it must end there — bouncing an
// authenticated user to the login page to report the outcome reads as "you were signed out".
function backToSettings(res, params) {
const qs = new URLSearchParams(params).toString();
res.redirect(`/app#/settings?${qs}`);
}
// Which providers this instance offers. Public: it is what draws the login buttons.
router.get('/providers', (req, res) => {
res.json({ providers: oidcProviders.publicList() });
@ -1233,10 +1240,15 @@ router.post('/sso/start', express.urlencoded({ extended: false }), (req, res) =>
res.redirect(startUrl);
});
router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
const provider = oidcProviders.get(req.params.slug);
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
/**
* Begin an OIDC round trip.
*
* `extra` is merged into the signed transaction, which is how LINK mode is carried: the tx is
* server-signed and lives in an httpOnly cookie, so the browser can neither read nor forge which
* account a link is for. Login and link therefore share one flow the same PKCE, state, nonce and
* verification instead of a second copy that drifts.
*/
async function beginOidc(req, res, provider, extra = {}, onError = backToApp, asJson = false) {
try {
const doc = await oidc.discover(provider.issuer);
const pkce = oidc.createPkce();
@ -1244,7 +1256,7 @@ router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
const state = oidc.randomToken();
const tx = jwt.sign(
{ typ: 'oidc-tx', slug: provider.slug, nonce, verifier: pkce.verifier, state },
{ typ: 'oidc-tx', slug: provider.slug, nonce, verifier: pkce.verifier, state, ...extra },
config.jwtSecret,
// HS256 explicitly, and a `typ` the session verifier does not accept: two token kinds signed
// with one secret must never be interchangeable, even if today only `slug` happens to stop it.
@ -1267,11 +1279,77 @@ router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', pkce.challenge);
url.searchParams.set('code_challenge_method', pkce.method);
/*
* A LINK start is fetched, not navigated to.
*
* The session lives in localStorage and travels as an Authorization header, so a top-level
* `location.href` to an authenticated route arrives anonymous which is exactly how this first
* shipped, and it 401'd every time. The caller therefore fetches this with its token and gets
* the authorize URL back to navigate to itself. The transaction cookie is still set by this
* response, because a same-origin fetch stores Set-Cookie normally.
*/
if (asJson) return res.json({ url: url.toString() });
res.redirect(url.toString());
} catch (err) {
console.error(`[oidc] ${req.params.slug} start failed:`, err.message);
backToApp(res, { sso_error: 'provider_unavailable' });
console.error(`[oidc] ${provider.slug} start failed:`, err.message);
if (asJson) return res.status(502).json({ error: 'The provider could not be reached' });
onError(res, { sso_error: 'provider_unavailable' });
}
}
router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
const provider = oidcProviders.get(req.params.slug);
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
await beginOidc(req, res, provider);
}));
/*
* Link an EXISTING account to an instance-wide provider.
*
* Signing in with a provider never adopts an account that has a password that would let anyone who
* can make a provider assert an address inherit the account behind it. So the owner proves they are
* the owner first, by being signed in, and starts the link themselves. The account is taken from the
* SESSION, never from the email in the returned token.
*
* 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. Org
* membership arrives through the normal org SSO path, which is domain-confined.
*/
/*
* Unlink, and set a password in the SAME operation.
*
* Not two steps. An account whose only credential is a provider has nothing to fall back on the
* moment that link is removed, so "unlink now, set a password next" leaves a window and a failure
* in between leaves an account nobody can sign into at all. The new password is therefore required
* up front and written in one transaction with the unlink.
*/
router.post('/oidc/unlink', requireAuth, (req, res) => {
const password = String((req.body || {}).password || '');
const user = db.prepare('SELECT id, email, auth_provider, password_hash FROM users WHERE id = ?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'Account not found' });
if (user.auth_provider === 'local') {
return res.status(400).json({ error: 'This account already signs in with a password' });
}
if (password.length < passwordReset.MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must be at least ${passwordReset.MIN_PASSWORD_LENGTH} characters` });
}
const was = user.auth_provider;
db.prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL, password_hash = ? WHERE id = ?")
.run(bcrypt.hashSync(password, 10), user.id);
logActivity(user.id, 'auth:sso_unlinked', `was ${was}`, null, getClientIp(req));
console.log(`[oidc] ${was} unlinked from ${user.email} (password set)`);
res.json({ ok: true, auth_provider: 'local' });
});
router.get('/oidc/:slug/link/start', requireAuth, asyncRoute(async (req, res) => {
const provider = oidcProviders.get(req.params.slug);
if (!provider) return res.status(404).json({ error: 'Unknown provider' });
if (provider.organizationId) {
return res.status(400).json({ error: 'Only this server\'s own providers can be linked' });
}
// JSON, not a redirect — see beginOidc. The browser cannot send a bearer token on a navigation.
await beginOidc(req, res, provider, { link: req.user.id }, backToSettings, true);
}));
router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
@ -1338,7 +1416,9 @@ router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
}
const email = String(claims.email || '').toLowerCase().trim();
if (!email) return backToApp(res, { sso_error: 'no_email' });
const linking = !!tx.link;
const fail = linking ? backToSettings : backToApp;
if (!email) return fail(res, { sso_error: 'no_email' });
/*
* AN ORGANIZATION'S PROVIDER MAY ONLY SPEAK FOR ITS OWN DOMAINS.
@ -1387,7 +1467,39 @@ router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
// explicit false is still refused, and an org-configured provider still cannot assume anything.
// See oidcProviders.emailIsVerified() for why that division is the safe one.
if (!oidcProviders.emailIsVerified(claims, provider)) {
return backToApp(res, { sso_error: 'email_unverified' });
return fail(res, { sso_error: 'email_unverified' });
}
/*
* LINK: attach this provider to the account that STARTED the link, and drop its password.
*
* The account comes from the signed transaction (i.e. from the session that began this), never
* from the returned email otherwise "linking" would be the very email-keyed takeover the login
* path refuses. The email must still match the account's own, because login resolves an account by
* the address the provider asserts: linking a different address would produce an account that
* cannot be signed into, or would collide with someone else's.
*
* The password is DELETED rather than kept alongside. One credential at a time is the whole point
* a password left behind is a second way in that the user believes they replaced.
*/
if (linking) {
const target = db.prepare('SELECT id, email, auth_provider FROM users WHERE id = ?').get(tx.link);
if (!target) return backToSettings(res, { sso_error: 'server_error' });
if (target.email.toLowerCase() !== email) {
console.warn(`[oidc] link refused: ${provider.slug} asserted ${email} for account ${target.email}`);
return backToSettings(res, { sso_error: 'link_email_mismatch' });
}
// Someone else already signed in with this provider identity. Two accounts must never share one
// provider subject, or whoever signs in second silently takes the first one's place.
const taken = db.prepare('SELECT id FROM users WHERE provider_id = ? AND auth_provider = ? AND id != ?')
.get(String(claims.sub), provider.slug, target.id);
if (taken) return backToSettings(res, { sso_error: 'link_already_used' });
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, password_hash = NULL, avatar_url = COALESCE(?, avatar_url) WHERE id = ?')
.run(provider.slug, String(claims.sub), claims.picture || null, target.id);
logActivity(target.id, 'auth:sso_linked', `provider=${provider.slug}`, null, getClientIp(req));
console.log(`[oidc] ${provider.slug} linked to ${target.email} (password cleared)`);
return backToSettings(res, { sso_linked: provider.slug });
}
try {

View file

@ -0,0 +1,68 @@
'use strict';
/*
* Opt-in install statistics the COLLECTOR side, plus the public aggregate the marketing
* page reads.
*
* Mounted only when TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither
* route. That gate is doing real work on both: the report endpoint is unauthenticated, and
* the aggregate would otherwise let any anonymous visitor read a private instance's screen
* count off its own landing page.
*
* A factory rather than a bare router so the database can be injected which is what lets
* this be tested at all. It was previously inline in server.js and had no tests.
*/
const express = require('express');
module.exports = function createTelemetryCollectorRouter(db) {
const router = express.Router();
/*
* Deliberately unauthenticated: a self-hosted instance has no credential with us, and
* issuing one would mean an enrolment handshake for what is a three-integer postcard.
*
* Upsert keyed on instance_id, so an install that reports daily occupies one row forever
* rather than 365 a year. Nothing here reads or stores the request IP receiving one is
* unavoidable, logging it would quietly make a pseudonymous report an identifiable one.
*/
router.post('/telemetry/report', express.json({ limit: '2kb' }), (req, res) => {
const { instance_id: id, version, screen_count: screens } = req.body || {};
// Validate rather than trust: this endpoint is open, so a malformed or hostile body must
// land as a 400, never as a row that poisons the count it exists to produce.
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) return res.status(400).json({ error: 'bad instance_id' });
if (version != null && (typeof version !== 'string' || version.length > 40)) return res.status(400).json({ error: 'bad version' });
if (!Number.isInteger(screens) || screens < 0 || screens > 100000) return res.status(400).json({ error: 'bad screen_count' });
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
VALUES (?, ?, ?, strftime('%s','now'), strftime('%s','now'))
ON CONFLICT(instance_id) DO UPDATE SET
version = excluded.version, screen_count = excluded.screen_count, last_seen = excluded.last_seen`)
.run(id, version || null, screens);
res.json({ ok: true });
});
/*
* The aggregate, for the marketing page. An aggregate across every install that reports,
* so it discloses nothing about any one of them.
*
* Cached, because this sits on a public landing page and the number moves in hours, not
* milliseconds. A scraper hitting it in a loop costs one query per interval, not per request.
*/
const STATS_TTL_MS = 5 * 60 * 1000;
let statsCache = { at: 0, body: null };
router.get('/public/stats', (req, res) => {
const now = Date.now();
if (!statsCache.body || now - statsCache.at > STATS_TTL_MS) {
const row = db.prepare(
'SELECT COUNT(*) AS installs, COALESCE(SUM(screen_count), 0) AS screens FROM telemetry_reports'
).get();
statsCache = { at: now, body: { screens: row.screens, installs: row.installs } };
}
// Public and cacheable, but never for long by a shared cache: the number is meant to climb.
res.set('Cache-Control', 'public, max-age=300');
res.json(statsCache.body);
});
return router;
};

View file

@ -1206,6 +1206,14 @@ function renderDirectorySearch(c) {
// ----- on-screen keyboard (drives the same filter path as typing) -----
if (cfg.show_onscreen_keyboard) {
/* Tell the platform not to raise ITS keyboard for this field. We autofocus a real
<input>, which on Android is the signal to throw the system IME over the bottom of
the screen - directly on top of the keyboard we draw below, so a directory panel
showed Google's keyboard (mic, GIF and emoji keys included) and never showed its
own. The buttons write input.value directly, so suppressing the platform keyboard
costs nothing here. Ignored by browsers that don't know inputmode, which is the
right fallback: a desktop preview keeps behaving exactly as before. */
input.setAttribute('inputmode', 'none');
var kb = document.getElementById('keyboard');
function press(ch) { input.value += ch; try { input.focus(); } catch(e){} onInput(); }
['1234567890','qwertyuiop','asdfghjkl','zxcvbnm'].forEach(function(r){

View file

@ -31,13 +31,14 @@ function probeVideoDims(filePath) {
}
async function probeImageDims(filePath) {
const sharp = require('sharp');
return imageDisplayDims(await sharp(filePath).metadata());
const imageOps = require('../lib/image-ops');
return imageDisplayDims(await imageOps.metadata(filePath));
}
async function regenImageThumb(filePath, thumbName) {
const sharp = require('sharp');
await sharp(filePath).rotate().resize(config.thumbnailWidth).jpeg({ quality: 70 }).toFile(path.join(config.contentDir, thumbName));
const imageOps = require('../lib/image-ops');
// Rotation is implicit: the decoder auto-orients per EXIF, which is what .rotate() bought here.
await imageOps.writeThumbnail(filePath, path.join(config.contentDir, thumbName), config.thumbnailWidth, 70);
}
(async () => {

View file

@ -977,6 +977,22 @@ app.get('/api/version', (req, res) => {
// Public status page
app.use('/api/status', require('./routes/status'));
/*
* Opt-in install statistics the COLLECTOR side, plus the public aggregate the marketing
* page reads. Both live in routes/telemetry-collector.js; both are mounted only when
* TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither.
*/
if (process.env.TELEMETRY_COLLECTOR === '1') {
/* `require('./db/database').db`, not the module-scope `db` that binding is declared far
below this line, so naming it here throws "Cannot access 'db' before initialization" at
load and the process never starts. The inline handler this replaced only touched `db`
inside a request callback, which runs long after the binding exists; passing it to a
factory made the reference eager. Every neighbouring call site in this region resolves
the same lazy way. */
app.use('/api', require('./routes/telemetry-collector')(require('./db/database').db));
console.log('[telemetry] collector enabled at POST /api/telemetry/report (+ GET /api/public/stats)');
}
// #146 BILLING: Usage Report on its OWN route (NOT part of /api/status — billing is revenue
// data and a heavier aggregate than the hot status path). bearerAuth is the dual front door:
// a 'billing:read' API token (Bearer st_...) OR a JWT session both reach it; the route's
@ -1214,6 +1230,7 @@ startContentExpiry(io);
const { startAlertService } = require('./services/alerts');
startAlertService(io);
// Start activation-nudge sweep (T+3 onboarding nudge; gated on HOSTED_INSTANCE)
const { startActivationNudge } = require('./services/activationNudge');
startActivationNudge();
@ -1248,6 +1265,13 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Handle provisioning via WebSocket notification
const { db } = require('./db/database');
// Opt-in install statistics — REPORTER side. Sends nothing until an operator says yes; the timer
// is unref'd so it can never hold the process open, and every failure path is silent and local.
// Must sit AFTER the `db` binding above: `const` is hoisted but uninitialised, so calling this
// earlier in the file throws "Cannot access 'db' before initialization" at load.
require('./lib/telemetry').start(db);
const originalProvisionRoute = require('./routes/provisioning');
// #161: device-owner QR provisioning. Returns the AOSP provisioning payload (DPC component + APK

View file

@ -59,6 +59,24 @@ test('show_onscreen_keyboard flag is carried into the page config', async () =>
assert.ok(html.includes('"show_onscreen_keyboard":false'), 'keyboard flag inlined (page hides the keyboard when false)');
});
test('the built-in keyboard suppresses the platform one', async () => {
// The page autofocuses a real <input>, which on Android raises the system IME over the
// bottom of the screen - covering the keyboard this widget draws itself. A panel showed
// Gboard, complete with a mic key, and never showed its own keyboard.
seed('search_kb_on', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: true });
const { html } = await fetchRender('search_kb_on');
assert.ok(/setAttribute\(\s*'inputmode'\s*,\s*'none'\s*\)/.test(html),
'page tells the platform not to raise its own keyboard');
// Only when we are drawing one. With the built-in keyboard off there is nothing to cover,
// and the platform keyboard is the only way left to type.
seed('search_kb_off2', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
const off = await fetchRender('search_kb_off2');
assert.ok(off.html.includes('"show_onscreen_keyboard":false'), 'flag inlined as false');
assert.ok(!/inputmode="none"/.test(off.html),
'the input is not statically marked inputmode=none - suppression is gated on the flag at runtime');
});
test('missing source -> friendly fallback page, not a 500', async () => {
seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
const { status, html } = await fetchRender('search_missing');

View file

@ -0,0 +1,94 @@
// The rest of the email tests mock nodemailer through require.cache, which proves our code
// calls sendMail correctly but says nothing about whether nodemailer still ACCEPTS what we
// hand it. That gap is exactly where a dependency bump breaks sending: the suite stays green
// while mail stops leaving the building.
//
// This drives the real library against a throwaway SMTP server on a loopback port, using the
// option and message shapes services/email.js actually builds. No network, no credentials.
const test = require('node:test');
const assert = require('node:assert');
const net = require('node:net');
const nodemailer = require('nodemailer');
const { buildSmtpMessage } = require('../services/email');
// A minimal SMTP server that answers enough of the protocol to accept one message and
// records the conversation, so assertions can be made about what went over the wire.
function startFakeSmtp() {
const seen = { commands: [], data: '' };
return new Promise((resolve) => {
const srv = net.createServer((sock) => {
let inData = false;
sock.write('220 fake.local ESMTP\r\n');
sock.on('data', (buf) => {
const chunk = buf.toString();
if (inData) {
seen.data += chunk;
if (/\r\n\.\r\n/.test(seen.data)) { inData = false; sock.write('250 OK queued\r\n'); }
return;
}
for (const line of chunk.split('\r\n').filter(Boolean)) {
seen.commands.push(line);
const cmd = line.split(' ')[0].toUpperCase();
if (cmd === 'EHLO' || cmd === 'HELO') sock.write('250-fake.local\r\n250 SIZE 10485760\r\n');
else if (cmd === 'MAIL' || cmd === 'RCPT') sock.write('250 OK\r\n');
else if (cmd === 'DATA') { inData = true; sock.write('354 send it\r\n'); }
else if (cmd === 'QUIT') { sock.write('221 bye\r\n'); sock.end(); }
else sock.write('250 OK\r\n');
}
});
sock.on('error', () => {}); // a client hanging up mid-conversation is not a test failure
});
srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen }));
});
}
test('the installed nodemailer accepts the options and messages we build', async () => {
const { srv, port, seen } = await startFakeSmtp();
try {
// The shape getSmtpTransporter() constructs.
const transporter = nodemailer.createTransport({
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
});
// The object `from` form, used when a display name overrides the configured one.
await transporter.sendMail({
from: { name: 'ScreenTinker', address: 'noreply@example.com' },
to: 'user@x.com',
subject: '[ScreenTinker] Hello',
html: '<p>hi there</p>',
text: 'hi there',
});
assert.ok(seen.commands.some(c => /^EHLO/i.test(c)), 'greets the server');
assert.ok(seen.commands.some(c => /^MAIL FROM:<noreply@example\.com>/i.test(c)),
'envelope sender is the configured address, not the display name');
assert.ok(seen.commands.some(c => /^RCPT TO:<user@x\.com>/i.test(c)), 'envelope recipient');
assert.match(seen.data, /Subject: \[ScreenTinker\] Hello/, 'subject and its prefix survive encoding');
assert.match(seen.data, /From: ScreenTinker <noreply@example\.com>/, 'display-name form still renders');
assert.match(seen.data, /Content-Type: multipart\/alternative/, 'text and html sent as alternatives');
// The string `from` form, used when there is no override.
await transporter.sendMail({
from: 'ScreenTinker <noreply@example.com>', to: 'user@x.com', subject: 'Welcome', html: '<p>x</p>',
});
transporter.close();
} finally {
srv.close();
}
});
test('buildSmtpMessage output is something the installed nodemailer can send', async () => {
const { srv, port, seen } = await startFakeSmtp();
try {
const transporter = nodemailer.createTransport({
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
});
// Built by our own code rather than hand-written here, so the two cannot drift apart.
await transporter.sendMail(buildSmtpMessage('to@x.com', 'Subj', 'plain', '<p>rich</p>', 'Sender Name'));
assert.ok(seen.commands.some(c => /^RCPT TO:<to@x\.com>/i.test(c)), 'recipient reached the wire');
assert.match(seen.data, /Subject: Subj/, 'subject reached the wire');
transporter.close();
} finally {
srv.close();
}
});

View file

@ -24,7 +24,15 @@ globalThis.localStorage = {
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
};
globalThis.navigator = globalThis.navigator || { language: 'en' };
// Node 22 added a built-in `navigator` global, defined as a getter with NO setter — so the plain
// assignment this used to do throws ("only a getter") under 'use strict' there, while being fine on
// Node 20 where the global does not exist at all. It is configurable, so define it rather than
// assign. Doing that unconditionally is also the more honest fixture: Node 22's own navigator
// reports the HOST locale (en-US here, something else on another machine or in CI), and a test that
// reads its language should not depend on where it runs.
Object.defineProperty(globalThis, 'navigator', {
value: { language: 'en' }, configurable: true, writable: true,
});
const MOD = pathToFileURL(path.join(__dirname, '..', '..', 'frontend', 'js', 'components', 'getting-started.js')).href;
let GS;

View file

@ -0,0 +1,132 @@
'use strict';
// Image decoding is pure JavaScript now (no native sharp), so its CPU cost lands on whichever
// thread runs it — ~1s of solid work for a 12MP photo. In-process that is a stalled event loop:
// no heartbeats, no socket traffic, panels marked offline, reconnect churn — #240 arriving from
// our own thumbnail backfill. lib/image-ops therefore hosts the work on a worker thread, and
// these bites pin the properties that makes it safe, none of which a functional test would catch.
const { test, after, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const sharp = require('sharp'); // devDependency: fixture generator only, never shipped
const imageOps = require('../lib/image-ops');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'image-ops-'));
after(async () => { await imageOps.shutdown(); fs.rmSync(tmp, { recursive: true, force: true }); });
// 12MP — a phone photo, and the size the thresholds below are calibrated against. Smaller is
// tempting for test speed but defeats the point: at 4MP the inline path stalls only ~350ms, which
// slips under any threshold loose enough not to be flaky, so the guard stops detecting the very
// regression it exists for. Measured: inline ~1000ms stall / ~2 timers serviced, worker ~0ms / ~90.
async function bigPhoto(name = 'big.jpg') {
const p = path.join(tmp, name);
if (!fs.existsSync(p)) {
// Random pixels, not a flat fill: a solid colour compresses to almost nothing and decodes far
// faster than any real photo, which would quietly defeat the timing assertion below.
const px = Buffer.allocUnsafe(4000 * 3000 * 3);
for (let i = 0; i < px.length; i++) px[i] = (i * 2654435761) & 0xff;
fs.writeFileSync(p, await sharp(px, { raw: { width: 4000, height: 3000, channels: 3 } }).jpeg().toBuffer());
}
return p;
}
test('image work does not stall the event loop (#240)', async () => {
const src = await bigPhoto();
let ticks = 0, worstGap = 0, last = Date.now();
const timer = setInterval(() => { ticks++; worstGap = Math.max(worstGap, Date.now() - last - 10); last = Date.now(); }, 10);
const started = Date.now();
await imageOps.writeThumbnail(src, path.join(tmp, 'thumb.jpg'), 320, 70);
const elapsed = Date.now() - started;
clearInterval(timer);
// The point is not that it was fast — it is that the loop kept running while it was slow.
// Thresholds sit in the gap between the two behaviours (worker ~90 ticks / ~0ms stall, inline
// ~2 ticks / ~1000ms stall), far enough from both to bite without being flaky.
assert.ok(ticks >= 20, `event loop serviced only ${ticks} timers in ${elapsed}ms — it is being blocked`);
assert.ok(worstGap < 200, `event loop stalled ${worstGap}ms in one go — image work is on the main thread`);
assert.ok(fs.existsSync(path.join(tmp, 'thumb.jpg')), 'thumbnail was still written');
});
test('an undecodable image rejects without killing the worker', async () => {
const bad = path.join(tmp, 'corrupt.jpg');
fs.writeFileSync(bad, Buffer.from('not an image'));
await assert.rejects(() => imageOps.metadata(bad), 'corrupt input must reject, so ingest records nulls');
// Crash isolation: one bad upload must not take out the queued work of unrelated callers.
const ok = path.join(tmp, 'fine.png');
fs.writeFileSync(ok, await sharp({ create: { width: 40, height: 25, channels: 3, background: '#123456' } }).png().toBuffer());
assert.deepEqual(await imageOps.metadata(ok), { width: 40, height: 25, orientation: 1 });
});
test('concurrent callers are serialized, and each still gets its own answer', async () => {
// Serialization bounds peak memory to ONE decoded bitmap (a 12MP photo is ~48MB of RGBA);
// overlapping jobs would multiply that by the queue depth on exactly the small targets this
// change exists to reach. Correctness under concurrency is what is asserted here.
const sizes = [[30, 10], [60, 20], [90, 30], [120, 40]];
const files = await Promise.all(sizes.map(async ([w, h], i) => {
const p = path.join(tmp, `c${i}.png`);
fs.writeFileSync(p, await sharp({ create: { width: w, height: h, channels: 3, background: '#0a0' } }).png().toBuffer());
return p;
}));
const got = await Promise.all(files.map(f => imageOps.metadata(f)));
assert.deepEqual(got.map(m => [m.width, m.height]), sizes, 'replies must not be crossed between queued jobs');
});
test('measureAndThumbnail decodes the file exactly once', async () => {
// Counted, not timed: a wall-clock comparison against metadata()+writeThumbnail() would be
// flaky under load, and this is an exact property. Asserted against image-ops-core directly
// because the decode happens on the worker thread, out of reach of a spy set up here.
// readImage() is the only reader in core, so readFile calls == decodes.
const core = require('../lib/image-ops-core');
const fsp = require('node:fs/promises');
const src = path.join(tmp, 'once.png');
fs.writeFileSync(src, await sharp({ create: { width: 200, height: 80, channels: 3, background: '#246' } }).png().toBuffer());
const spy = mock.method(fsp, 'readFile');
// Count reads OF THIS FILE only. Node's ESM loader also reads through fs.promises.readFile, so
// a raw call count picks up jimp's and the WASM codecs' lazy module loading on first use.
const decodes = () => spy.mock.calls.filter(c => String(c.arguments[0]) === src).length;
try {
const r = await core.measureAndThumbnail(src, path.join(tmp, 'once-thumb.jpg'), 100, 70);
assert.equal(decodes(), 1, 'combined op must decode once, not once per answer');
assert.deepEqual([r.width, r.height], [200, 80]);
assert.equal(r.thumbnailWritten, true);
// The pairing it replaces, for contrast — this is the cost being removed.
spy.mock.resetCalls();
await core.metadata(src);
await core.writeThumbnail(src, path.join(tmp, 'twice-thumb.jpg'), 100, 70);
assert.equal(decodes(), 2, 'the separate calls are what cost two decodes');
} finally { spy.mock.restore(); }
});
test('a thumbnail that cannot be written still yields dimensions', async () => {
// Dimensions are independently useful — the player needs them to letterbox — and the two-call
// version kept them, because width/height were assigned before the thumbnail was attempted.
// Merging the calls must not quietly turn a thumbnail failure into a total metadata failure.
const src = path.join(tmp, 'ok.png');
fs.writeFileSync(src, await sharp({ create: { width: 150, height: 60, channels: 3, background: '#654' } }).png().toBuffer());
const undirectable = path.join(tmp, 'no-such-dir', 'thumb.jpg'); // parent does not exist
const r = await imageOps.measureAndThumbnail(src, undirectable, 100, 70);
assert.deepEqual([r.width, r.height], [150, 60], 'dimensions survive a thumbnail write failure');
assert.equal(r.thumbnailWritten, false);
assert.match(r.thumbnailError || '', /ENOENT|no such file/i);
});
test('#170 EXIF orientation is applied by the decoder, so dimensions are as DISPLAYED', async () => {
// orientation 6 = "rotate 90° CW to display": a 30x100 stored buffer DISPLAYS as 100x30.
const p = path.join(tmp, 'rot6.jpg');
fs.writeFileSync(p, await sharp({ create: { width: 30, height: 100, channels: 3, background: '#00ff00' } })
.withMetadata({ orientation: 6 }).jpeg().toBuffer());
const meta = await imageOps.metadata(p);
assert.equal(meta.width, 100, 'EXIF-rotated image measures as displayed, not as stored');
assert.equal(meta.height, 30);
// Reported as 1 because the rotation is already applied — imageDisplayDims() must NOT swap again.
assert.equal(meta.orientation, 1, 'a tag of 6 here would double-rotate downstream');
});

View file

@ -0,0 +1,63 @@
'use strict';
/*
* Identifier-first login (#258).
*
* The password box does not exist until an address has been submitted. That is what lets the
* organization lookup happen BEFORE a credential is offered, so someone whose company requires its
* own identity provider is never shown a password box that is going to be refused.
*
* Verified in a real browser as well (password hidden -> submit -> visible + focused -> edit the
* address -> hidden again); these assertions stop the wiring being removed silently.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const LOGIN = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'login.js'), 'utf8');
test('password visibility depends on BOTH identification and SSO-only', () => {
assert.match(LOGIN, /const showPassword = identified && !ssoOnlyDomain;/,
'the two drivers must be combined in one place so they cannot disagree');
});
test('the primary button advances before it signs in', () => {
assert.match(LOGIN, /if \(identified && !ssoOnlyDomain\) return doLogin\(\);\s*\n\s*identify\(\);/,
'the button must identify first and only sign in once an address is known');
assert.match(LOGIN, /btn\.textContent = identified && !ssoOnlyDomain \? t\('auth\.sign_in'\) : t\('auth\.next'\)/);
});
test('editing the address returns to the identifier step', () => {
assert.match(LOGIN, /if \(!identified\) return;\s*\n\s*identified = false;/,
'a corrected address must get a fresh answer, not the previous domain\'s');
});
test('the per-keystroke lookup is gone', () => {
assert.doesNotMatch(LOGIN, /ssoLookupTimer/,
'the debounced lookup answered for half-typed domains and burned a 10/min budget');
assert.match(LOGIN, /async function identify\(\)[\s\S]{0,400}await lookupOrgSso\(email\)/,
'the lookup now runs on submit');
});
test('instance-wide providers are never hidden', () => {
// Deliberate: they are the operator's, offered to everyone, and the server refuses them for an
// SSO-only organization anyway. Hiding them made the page change shape while typing.
assert.doesNotMatch(LOGIN, /getElementById\('instanceProviders'\)[\s\S]{0,120}style\.display/,
'nothing may hide #instanceProviders');
});
test('first-run setup skips identifier-first', () => {
assert.match(LOGIN, /if \(isSetup\) identified = true;/,
'creating the first admin needs both fields at once');
});
test('the initial state is applied after its declarations (temporal dead zone)', () => {
const decl = LOGIN.indexOf('let identified = false;');
const call = LOGIN.lastIndexOf('\n applyFormState();');
assert.ok(decl !== -1 && call !== -1, 'both the declaration and the init call must exist');
assert.ok(call > decl,
'applyFormState() must be called AFTER the let declarations — earlier throws on the TDZ, which '
+ 'on this page means a login form that never renders');
});

View file

@ -0,0 +1,120 @@
'use strict';
/*
* Linking an existing account to an instance-wide provider (#258).
*
* 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 an escape hatch ("the owner signs
* in locally and links from Settings") that was never built, so an account created with a password
* could never use SSO at all.
*
* The rules this pins down, all of which are load-bearing:
* - the account being linked comes from the SIGNED TRANSACTION (i.e. the session that started the
* link), never from the email in the returned token. Otherwise "linking" is the same email-keyed
* takeover under a friendlier name;
* - the provider's email must still equal the account's, because login resolves accounts by the
* asserted address;
* - one provider subject may not be linked to two accounts;
* - linking DELETES the password: one credential at a time;
* - unlinking SETS a password in the same statement, so the account is never between credentials;
* - ORG providers are not linkable at all.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const AUTH = fs.readFileSync(path.join(__dirname, '..', 'routes', 'auth.js'), 'utf8');
/** Body of a route handler, from its `router.<verb>('<route>'` to the next `router.`. */
function handler(verb, route) {
const start = AUTH.indexOf(`router.${verb}('${route}'`);
assert.notEqual(start, -1, `route ${verb.toUpperCase()} ${route} not found`);
const rest = AUTH.slice(start + 1);
const end = rest.indexOf('\nrouter.');
return end === -1 ? rest : rest.slice(0, end);
}
test('link start requires authentication and refuses org providers', () => {
const body = handler('get', '/oidc/:slug/link/start');
assert.match(AUTH, /router\.get\('\/oidc\/:slug\/link\/start', requireAuth/,
'the link must be startable only by someone already signed in — that is the proof of ownership');
assert.match(body, /provider\.organizationId[\s\S]{0,160}status\(400\)/,
"an organization's provider must never attach itself to a platform account");
assert.match(body, /link: req\.user\.id/,
'the account must come from the session, not from anything the browser can set');
});
test('the linked account is taken from the transaction, never from the returned email', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /WHERE id = \?'\)\.get\(tx\.link\)/,
'the target account is looked up by tx.link (the session that started it)');
// The email is still checked, but as a constraint on the link — not as the way the account is found.
assert.match(cb, /target\.email\.toLowerCase\(\) !== email/, 'email must match the account being linked');
assert.match(cb, /link_email_mismatch/);
});
test('one provider subject cannot be linked to two accounts', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /provider_id = \? AND auth_provider = \? AND id != \?/,
'must check whether this provider identity already belongs to another account');
assert.match(cb, /link_already_used/);
});
test('linking deletes the password — one credential at a time', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /UPDATE users SET auth_provider = \?, provider_id = \?, password_hash = NULL/,
'the password must be cleared in the same statement that attaches the provider');
});
test('unlinking sets a password in the SAME statement', () => {
const body = handler('post', '/oidc/unlink');
assert.match(body, /UPDATE users SET auth_provider = 'local', provider_id = NULL, password_hash = \?/,
'unlink and set-password must be one write — never unlink first and set a password after');
assert.match(body, /password\.length < passwordReset\.MIN_PASSWORD_LENGTH/,
'the replacement password must meet the same minimum as a reset');
assert.match(body, /auth_provider === 'local'/, 'refuse unlinking an account that has no provider');
});
test('both link and unlink are recorded in the activity log', () => {
assert.match(handler('get', '/oidc/:slug/callback'), /logActivity\([^)]*'auth:sso_linked'/);
assert.match(handler('post', '/oidc/unlink'), /logActivity\([^)]*'auth:sso_unlinked'/);
});
test('link failures return to Settings, not the login page', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /const fail = linking \? backToSettings : backToApp/,
'an authenticated user must not be bounced to a login screen to be told the link failed');
assert.match(AUTH, /function backToSettings\(res, params\)[\s\S]{0,200}#\/settings/);
});
test('link start answers with JSON, because a navigation cannot carry a bearer token', () => {
/*
* Shipped broken once: the Settings button did `location.href = .../link/start`, which is a
* top-level navigation. The session lives in localStorage and travels as an Authorization header,
* so the request arrived anonymous and requireAuth refused it "Authentication required" on
* every click. The client must FETCH this with its token and navigate to the returned URL.
*/
const body = handler('get', '/oidc/:slug/link/start');
assert.match(body, /beginOidc\([^)]*backToSettings, true\)/,
'link start must run in JSON mode');
assert.match(AUTH, /if \(asJson\) return res\.json\(\{ url: url\.toString\(\) \}\);/,
'JSON mode must return the authorize URL rather than a 302');
const settings = require('fs').readFileSync(
require('path').join(__dirname, '..', '..', 'frontend', 'js', 'views', 'settings.js'), 'utf8');
assert.match(settings, /await api\.ssoLinkStart\(slug\)/,
'the client must fetch the start route so its Authorization header is sent');
assert.doesNotMatch(settings, /location\.href = `\/api\/auth\/oidc/,
'never navigate straight at the authenticated start route');
});
test('login and link share one flow, so verification cannot drift between them', () => {
// beginOidc is the single place PKCE/state/nonce are minted; both entry points call it.
assert.match(AUTH, /async function beginOidc\(req, res, provider, extra = \{\}/);
const login = handler('get', '/oidc/:slug/start');
const link = handler('get', '/oidc/:slug/link/start');
assert.match(login, /beginOidc\(req, res, provider\)/);
assert.match(link, /beginOidc\(req, res, provider, \{ link: req\.user\.id \}/);
});

View file

@ -9,10 +9,14 @@
* never pushed a Microsoft-shaped token through the policy, so nothing failed.
*
* The rule these tests pin down:
* - `email_verified: true` -> believed, always
* - claim ABSENT + operator-chosen -> believed (Microsoft, or an opted-in generic provider)
* - claim ABSENT + org-configured -> refused
* - `email_verified: false` -> refused, whoever asked
* - `email_verified: true` -> believed, always
* - claim ABSENT + operator-chosen -> believed (Microsoft, or an opted-in provider)
* - claim ABSENT + org with a VERIFIED domain -> believed (it proved DNS control)
* - claim ABSENT + org with NO verified domain -> refused (it has proven nothing)
* - `email_verified: false` -> refused, whoever asked
*
* The org case is a consequence of DNS proof, never a setting: an organization must not be able to
* turn it on for itself, so it is derived and never read from a column.
*/
const { test } = require('node:test');
@ -22,10 +26,18 @@ const { emailIsVerified, list } = require('../lib/oidc-providers');
const MS_ENV = { MICROSOFT_CLIENT_ID: 'client-abc', MICROSOFT_TENANT_ID: 'ffffffff-1111-2222-3333-444444444444' };
const microsoft = () => list(MS_ENV).find((p) => p.slug === 'microsoft');
const google = () => list({ GOOGLE_CLIENT_ID: 'g-abc' }).find((p) => p.slug === 'google');
const orgProvider = { slug: 'acme7f3', source: 'org', organizationId: 'org-1', assumeEmailVerified: false };
// An org provider as rowToProvider builds it: `assumeEmailVerified` follows from whether any domain
// has actually been DNS-verified.
const orgProvider = (verifiedDomains = []) => ({
slug: 'acme7f3', source: 'org', organizationId: 'org-1',
emailDomains: verifiedDomains.join(','),
assumeEmailVerified: verifiedDomains.length > 0,
});
const orgUnproven = orgProvider(); // configured, nothing verified yet
const orgProven = orgProvider(['bytetinker.net']); // TXT published, domain green
test('an explicit true is believed from any provider', () => {
for (const p of [microsoft(), google(), orgProvider]) {
for (const p of [microsoft(), google(), orgUnproven, orgProven]) {
assert.equal(emailIsVerified({ email_verified: true }, p), true, `${p.slug} should accept an explicit true`);
}
});
@ -42,14 +54,26 @@ test('Google stays strict — it does send the claim, so there is nothing to ass
assert.equal(emailIsVerified({ email: 'someone@example.com' }, g), false);
});
test('an ORG-configured provider may never assume, even if the object claims it can', () => {
assert.equal(emailIsVerified({ email: 'a@b.c' }, orgProvider), false);
// Belt and braces: a tampered/hand-built org object must not be able to opt itself in through
// the database, which is why rowToProvider pins the field rather than reading a column.
test('an org provider that has proven a domain may assume — the customer-Entra case', () => {
// Entra sends no email_verified. Before this, the domain went green and the login still failed.
assert.equal(emailIsVerified({ email: 'dan@bytetinker.net' }, orgProven), true);
});
test('an org provider that has proven NOTHING assumes nothing', () => {
assert.equal(emailIsVerified({ email: 'dan@bytetinker.net' }, orgUnproven), false);
});
test('the org assumption is DERIVED from proof, never readable from the row', () => {
const src = require('fs').readFileSync(require.resolve('../lib/oidc-providers'), 'utf8');
assert.match(src, /assumeEmailVerified: false,\s*\n\s*source: 'org'/,
'rowToProvider must hard-code assumeEmailVerified:false next to source:org');
assert.match(src, /assumeEmailVerified: verified\.length > 0,\s*\n\s*source: 'org'/,
'rowToProvider must derive it from the verified-domain list, next to source:org');
// The whole point: an organization must not be able to switch this on for itself.
assert.doesNotMatch(src, /assumeEmailVerified: *row\./, 'must never be read from the org row');
// ...and there must be no column for it to be read FROM. Checked against the schema rather than
// this file, where `OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED` is a legitimate operator-set env var.
const schema = require('fs').readFileSync(require.resolve('../db/database'), 'utf8');
assert.doesNotMatch(schema, /assume_email_verified/i,
'org_sso_providers must have no assume_email_verified column');
});
test('an EXPLICIT false is refused even where absence would be assumed', () => {

View file

@ -0,0 +1,94 @@
'use strict';
// The collector and the public aggregate it feeds. Both were previously inline in server.js
// with no test at all — the endpoint that decides what a public marketing page claims, and the
// unauthenticated one anyone on the internet can POST to.
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE telemetry_reports (
instance_id TEXT PRIMARY KEY,
version TEXT,
screen_count INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);`);
const app = express();
app.use('/api', require('../routes/telemetry-collector')(db));
const server = app.listen(0);
let base;
test.before(async () => {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
base = `http://127.0.0.1:${server.address().port}`;
});
test.after(() => server.close());
const uuid = (n) => `0000000${n}-0000-4000-8000-00000000000${n}`.slice(0, 36).padEnd(36, '0');
const report = (body) => fetch(`${base}/api/telemetry/report`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
test('a well-formed report is accepted and stored', async () => {
const res = await report({ instance_id: uuid(1), version: '1.9.34', screen_count: 12 });
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true });
const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1));
assert.equal(row.screen_count, 12);
assert.equal(row.version, '1.9.34');
});
test('reporting again updates the row rather than adding one', async () => {
// An install reports daily. If this ever inserted instead of updating, one install would
// occupy 365 rows a year and the public figure would count it 365 times.
await report({ instance_id: uuid(1), version: '1.9.35', screen_count: 20 });
const n = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports WHERE instance_id = ?').get(uuid(1)).c;
assert.equal(n, 1, 'still a single row for this install');
const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1));
assert.equal(row.screen_count, 20, 'count is the latest reported');
assert.equal(row.version, '1.9.35');
});
test('hostile or malformed bodies are refused, not stored', async () => {
const before = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c;
const bad = [
{ instance_id: 'not-a-uuid', screen_count: 1 },
{ instance_id: uuid(2) }, // no count
{ instance_id: uuid(2), screen_count: -1 },
{ instance_id: uuid(2), screen_count: 1e9 }, // absurd, would skew the total
{ instance_id: uuid(2), screen_count: 1.5 }, // not an integer
{ instance_id: uuid(2), screen_count: 1, version: 'v'.repeat(41) },
{},
];
for (const body of bad) {
const res = await report(body);
assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
}
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c, before,
'nothing rejected reached the table');
});
test('the public aggregate sums screens across installs and names no one', async () => {
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
VALUES (?,?,?,?,?)`).run(uuid(3), '1.9.34', 480, 1, 1);
const res = await fetch(`${base}/api/public/stats`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.screens, 500, '20 + 480');
assert.equal(body.installs, 2);
// The whole payload — no instance ids, no versions, nothing per-install.
assert.deepEqual(Object.keys(body).sort(), ['installs', 'screens']);
assert.match(res.headers.get('cache-control') || '', /max-age=300/);
});
test('the aggregate is cached, so a scraper cannot turn page views into queries', async () => {
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
VALUES (?,?,?,?,?)`).run(uuid(4), '1.9.34', 999, 1, 1);
const body = await (await fetch(`${base}/api/public/stats`)).json();
assert.equal(body.screens, 500, 'still the cached figure, not 1499');
});

View file

@ -0,0 +1,232 @@
'use strict';
// Opt-in install statistics. The promises this feature makes are all negative ones — it does not
// send until asked, it does not send more than three fields, it does not ask twice — and a
// negative promise is exactly the kind that rots silently. These bites pin each one.
const { test, after, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-'));
process.env.DATA_DIR = tmp;
process.env.NODE_ENV = 'test';
const { db } = require('../db/database');
const appSettings = require('../lib/app-settings');
const telemetry = require('../lib/telemetry');
after(() => { telemetry.stop(); fs.rmSync(tmp, { recursive: true, force: true }); });
function reset() {
db.prepare('DELETE FROM app_settings').run();
appSettings.__reload();
}
test('an install that has not been asked reports nothing', async () => {
reset();
assert.equal(telemetry.state(), 'unasked');
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
try {
const r = await telemetry.report(db);
assert.equal(r.sent, false); assert.equal(r.reason, 'not_enabled');
assert.equal(spy.mock.callCount(), 0, 'no outbound request may be made before consent');
} finally { spy.mock.restore(); }
});
test('declining is remembered, so the prompt does not return after an update', () => {
reset();
telemetry.setEnabled(false);
assert.equal(telemetry.state(), 'off', 'a decline must persist as off, never fall back to unasked');
appSettings.__reload(); // survives a restart
assert.equal(telemetry.state(), 'off');
});
test('a declined install still reports nothing', async () => {
reset();
telemetry.setEnabled(false);
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
try {
const d = await telemetry.report(db); assert.equal(d.sent, false); assert.equal(d.reason, 'not_enabled');
assert.equal(spy.mock.callCount(), 0);
} finally { spy.mock.restore(); }
});
test('the payload is exactly three fields, and no more', async () => {
reset();
const body = telemetry.payload(db);
assert.deepEqual(Object.keys(body).sort(), ['instance_id', 'screen_count', 'version'],
'adding a field here is a privacy decision, not a refactor — it must fail this test first');
assert.match(body.instance_id, /^[0-9a-f-]{36}$/i);
assert.equal(typeof body.version, 'string');
assert.equal(typeof body.screen_count, 'number');
});
test('the instance id is stable across reads and restarts', () => {
reset();
const first = telemetry.instanceId();
assert.equal(telemetry.instanceId(), first, 'must not mint a new id per call');
appSettings.__reload();
assert.equal(telemetry.instanceId(), first, 'must survive a restart, or every install counts twice');
});
test('screen_count counts paired displays, not provisioning rows', () => {
reset();
db.prepare('DELETE FROM devices').run();
const ins = db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES (?, ?, ?, ?, 'offline')");
ins.run('d1', 'One', '111111', 'tok1');
ins.run('d2', 'Two', '222222', 'tok2');
// Never paired: a provisioning row nobody connected is not a deployed screen.
db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES ('d3','Three','333333',NULL,'offline')").run();
assert.equal(telemetry.payload(db).screen_count, 2);
db.prepare('DELETE FROM devices').run();
});
test('when enabled it sends exactly the payload, and records what it sent', async () => {
reset();
telemetry.setEnabled(true);
let seen = null;
const spy = mock.method(globalThis, 'fetch', async (url, opts) => {
seen = { url, body: JSON.parse(opts.body), method: opts.method };
return { ok: true, status: 200 };
});
try {
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
assert.equal(r.sent, true);
assert.equal(seen.method, 'POST');
assert.equal(seen.url, 'https://example.test/report');
assert.deepEqual(Object.keys(seen.body).sort(), ['instance_id', 'screen_count', 'version'],
'the bytes on the wire must match the audited payload, not a superset');
// An operator can check rather than trust: what was sent is retrievable verbatim.
const last = telemetry.getLastReport();
assert.deepEqual(last.body, seen.body);
assert.equal(typeof last.at, 'number');
} finally { spy.mock.restore(); }
});
test('a blocked outbound connection is recorded, with the address that was blocked', async () => {
// Egress filtering is the normal failure on a self-hosted box and is otherwise invisible: the
// operator sees nothing arriving and cannot tell a firewall from a broken feature. The UI can
// only name the host to allowlist if the failure is recorded here.
reset();
telemetry.setEnabled(true);
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
try {
await telemetry.report(db, { urls: [{ url: 'https://stats.example.test/report', kind: 'screentinker' }] });
const err = telemetry.getLastError();
assert.ok(err, 'a failed attempt must be recorded, or the operator has nothing to act on');
assert.equal(err.reason, 'network');
assert.equal(err.url, 'https://stats.example.test/report', 'must record the address actually tried');
assert.equal(typeof err.at, 'number');
} finally { spy.mock.restore(); }
});
test('a later success clears the stale failure', async () => {
reset();
telemetry.setEnabled(true);
const bad = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
try { await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] }); } finally { bad.mock.restore(); }
assert.ok(telemetry.getLastError(), 'precondition: a failure was recorded');
const good = mock.method(globalThis, 'fetch', async () => ({ ok: true, status: 200 }));
try {
await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
assert.equal(telemetry.getLastError(), null,
'a stale firewall warning must not outlive the problem it describes');
} finally { good.mock.restore(); }
});
test('an operator collector is ADDITIONAL — it never replaces the shared report', async () => {
// The whole point of naming it EXTRA rather than ENDPOINT: configuring your own collector must
// not silently redirect the report the operator agreed to share. If this ever becomes a
// redirect, the opt-in stops meaning what the UI says it means.
reset();
telemetry.setEnabled(true);
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
try {
const dests = telemetry.destinations();
assert.equal(dests.length, 2, 'sharing on + own collector = both, never one');
assert.deepEqual(dests.map(d => d.kind).sort(), ['extra', 'screentinker']);
const hits = [];
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
try {
await telemetry.report(db);
assert.equal(hits.length, 2, 'both destinations must receive the report');
assert.ok(hits.includes('https://mine.example.test/collect'));
assert.ok(hits.some(u => u.includes('screentinker.com')), 'the shared report must still be sent');
} finally { spy.mock.restore(); }
} finally {
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
}
});
test('an operator can keep their own statistics while sharing nothing with us', async () => {
// Someone who wants internal fleet numbers but nothing leaving for us sets their own collector
// and leaves sharing off. Supported on purpose: it is their server posting to their host.
reset();
telemetry.setEnabled(false);
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
try {
const hits = [];
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
try {
await telemetry.report(db);
assert.deepEqual(hits, ['https://mine.example.test/collect']);
assert.ok(!hits.some(u => u.includes('screentinker.com')),
'sharing is off — nothing may reach us, whatever else is configured');
} finally { spy.mock.restore(); }
} finally {
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
}
});
test('one unreachable destination does not stop the other', async () => {
reset();
telemetry.setEnabled(true);
const spy = mock.method(globalThis, 'fetch', async (url) => {
if (url.includes('broken')) throw new Error('ECONNREFUSED');
return { ok: true, status: 200 };
});
try {
const r = await telemetry.report(db, { urls: [
{ url: 'https://broken.example.test/a', kind: 'extra' },
{ url: 'https://working.example.test/b', kind: 'screentinker' },
] });
assert.equal(r.results.filter(x => x.sent).length, 1, 'the reachable one still receives it');
assert.equal(r.results.filter(x => !x.sent).length, 1);
assert.equal(telemetry.getLastError().url, 'https://broken.example.test/a',
'the failure names the destination that actually failed');
} finally { spy.mock.restore(); }
});
test('a failed send is quiet and local — never throws, never records a phantom report', async () => {
reset();
telemetry.setEnabled(true);
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
try {
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
assert.equal(r.sent, false);
assert.equal(r.reason, 'network');
assert.equal(telemetry.getLastReport(), null, 'a failed send must not look like a successful one');
} finally { spy.mock.restore(); }
// An HTTP error is likewise not a success.
const spy2 = mock.method(globalThis, 'fetch', async () => ({ ok: false, status: 503 }));
try {
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
assert.equal(r.sent, false);
assert.equal(r.reason, 'http_503');
assert.equal(telemetry.getLastReport(), null);
} finally { spy2.mock.restore(); }
});

View file

@ -0,0 +1,68 @@
'use strict';
/*
* Prerelease ordering.
*
* The bug this pins: a plain string compare put every build from alpha10 onward BELOW alpha8,
* because '1' < '8'. The OTA check then answered `client-newer` and refused to offer the update,
* so a fleet on alpha8 could not be moved forward silently, while the server reported the newer
* build as `latest` in the same response. Two comparators carried the assumption, each with a
* comment saying lexical was fine "for our naming". It was, until the counter passed 9.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { preCmp } = require('../lib/version-precedence');
const { cmp } = require('../lib/ota-breaker');
const bsUpdate = require('../lib/brightsign-update');
const sign = (n) => (n === 0 ? 0 : n < 0 ? -1 : 1);
test('double-digit prereleases outrank single-digit ones', () => {
// The exact case that stranded the fleet.
assert.equal(sign(preCmp('alpha11', 'alpha8')), 1, 'alpha11 must be newer than alpha8');
assert.equal(sign(preCmp('alpha10', 'alpha9')), 1);
assert.equal(sign(preCmp('beta12', 'beta2')), 1);
assert.equal(sign(preCmp('rc10', 'rc9')), 1);
// And the reverse still holds, so nothing was merely inverted.
assert.equal(sign(preCmp('alpha2', 'alpha10')), -1);
});
test('ordinary alphabetical precedence is unchanged', () => {
assert.equal(sign(preCmp('beta1', 'alpha11')), 1, 'beta outranks alpha regardless of number');
assert.equal(sign(preCmp('rc1', 'beta9')), 1, 'rc outranks beta');
assert.equal(sign(preCmp('alpha8', 'alpha8')), 0);
});
test('semver dot form works too, so the naming can move without another fix', () => {
assert.equal(sign(preCmp('alpha.11', 'alpha.8')), 1);
assert.equal(sign(preCmp('alpha', 'alpha.1')), -1, 'fewer identifiers = lower precedence');
assert.equal(sign(preCmp('alpha.1', 'beta.1')), -1);
});
test('OTA: a device on alpha8 is offered alpha11', () => {
// Through the real comparator the update check uses, not just the helper.
assert.equal(cmp('1.9.34-alpha11', '1.9.34-alpha8'), 1);
assert.equal(cmp('1.9.34-alpha10', '1.9.34-alpha6'), 1);
// A release still outranks any prerelease of the same core.
assert.equal(cmp('1.9.34', '1.9.34-alpha11'), 1);
// And a newer core still wins outright, whatever the prerelease says.
assert.equal(cmp('1.9.35-alpha1', '1.9.34-alpha11'), 1);
});
test('OTA decide(): alpha8 -> alpha11 is an offer, not client-newer', () => {
// The end-to-end symptom: the endpoint reported the newer build as `latest` and refused it
// in the same breath.
const { decide } = require('../lib/ota-breaker');
const d = decide('1.9.34-alpha8', '1.9.34-alpha11', 'test-device-precedence');
assert.equal(d.update_available, true, `expected an offer, got ${d.reason}`);
assert.notEqual(d.reason, 'client-newer');
});
test('BrightSign host packages order the same way', () => {
// Same assumption lived here, with the same comment. A BrightSign package update that goes
// wrong replaces the script that boots the player, so wrong-way ordering matters more here.
assert.equal(sign(bsUpdate.compareVersions('1.9.34-rc10', '1.9.34-rc9')), 1);
assert.equal(sign(bsUpdate.compareVersions('1.9.34', '1.9.34-rc10')), 1);
assert.equal(sign(bsUpdate.compareVersions('1.9.34-alpha2', '1.9.34-alpha10')), -1);
});

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
id="http://screentinker.com/player" version="1.9.34" viewmodes="maximized">
id="http://screentinker.com/player" version="1.9.36" viewmodes="maximized">
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
<tizen:profile name="tv"/>
<name>ScreenTinker</name>