Compare commits

...

35 commits

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

TWO GATES, BOTH FAIL CLOSED.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* OTA: prove the destination is writable before downloading

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

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

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

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

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

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

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

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

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

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

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

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

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

The entire payload is three fields:

    { instance_id, version, screen_count }

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

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

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

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

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

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

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

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

Both APK download paths did:

    File(context.getExternalFilesDir(DIRECTORY_DOWNLOADS), name)

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

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

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

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

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

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

Root cause is inferred from converging evidence on an Android 9 panel (HTTP served
at each failure, no APK anywhere on the device, the app's own external files dir
denied to its own UID, both paths failing identically, signing verified at parity
against the release it already installed). Handling a null return is correct
regardless — it must never become a relative path.
2026-08-13 14:32:34 -05:00
42 changed files with 2466 additions and 284 deletions

View file

@ -86,6 +86,39 @@ jobs:
working-directory: android working-directory: android
run: ./gradlew :app:testDebugUnitTest --no-daemon run: ./gradlew :app:testDebugUnitTest --no-daemon
# Every artifact that can enter the APK must have a licence on file. This runs here
# rather than in its own job because the Gradle cache and Android SDK are already warm.
- name: Licence gate (APK runtime classpath)
run: node scripts/android-license-check.js
licenses:
name: Licence gate + SBOM (production deps)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: server/package-lock.json
# --omit=dev on purpose, and it is the whole point of the job. A developer checkout
# carries sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later; it is a test
# fixture generator that never reaches a server. Auditing anything other than a
# production install would report a licence we do not actually ship.
- name: Install production dependencies only
working-directory: server
run: npm ci --omit=dev
- name: Licence gate
run: node scripts/license-check.js --sbom sbom/screentinker-server.cdx.json
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom/
if-no-files-found: error
smoke: smoke:
name: Boot smoke + version check name: Boot smoke + version check
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -109,6 +142,12 @@ jobs:
working-directory: server working-directory: server
env: env:
SELF_HOSTED: 'true' 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: | run: |
node server.js > "$RUNNER_TEMP/server.log" 2>&1 & node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
echo $! > "$RUNNER_TEMP/server.pid" echo $! > "$RUNNER_TEMP/server.pid"
@ -131,6 +170,21 @@ jobs:
test "$REPORTED" = "$EXPECTED" test "$REPORTED" = "$EXPECTED"
echo "OK: status ok, version $REPORTED matches VERSION" 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 - name: Stop server
if: always() if: always()
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true

View file

@ -85,6 +85,15 @@ jobs:
./scripts/build-autorun-zip.sh -o autorun.zip ./scripts/build-autorun-zip.sh -o autorun.zip
ls -la autorun.zip ls -la autorun.zip
# A published SBOM is what turns "we track licences" into something a customer or an
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
- name: Generate SBOM (production dependencies)
run: |
( cd server && npm ci --omit=dev )
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
ls -la screentinker-sbom-*.cdx.json
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh) - name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
run: | run: |
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz" OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
@ -100,15 +109,42 @@ jobs:
- name: Generate release notes - name: Generate release notes
run: | run: |
PREV="${{ steps.ver.outputs.prev }}" 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 "## ScreenTinker ${{ steps.ver.outputs.tag }}"
echo echo
echo "### Changes" if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
if [ -n "$PREV" ]; then echo "$CHANGELOG_BODY"
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
else else
echo "_First tagged release. Most recent changes:_" # No entry for this version — fall back to commit subjects rather than publish a
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}" # 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 fi
echo echo
echo "### Artifacts" echo "### Artifacts"
@ -127,6 +163,7 @@ jobs:
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)." echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)." echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
} > RELEASE_NOTES.md } > RELEASE_NOTES.md
cat RELEASE_NOTES.md cat RELEASE_NOTES.md
@ -144,6 +181,7 @@ jobs:
--notes-file RELEASE_NOTES.md \ --notes-file RELEASE_NOTES.md \
"${TARBALL}" \ "${TARBALL}" \
autorun.zip \ autorun.zip \
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
tizen/ScreenTinker.wgt tizen/ScreenTinker.wgt
docker: docker:

4
.gitignore vendored
View file

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

View file

@ -1,271 +1,392 @@
# Changelog # Changelog
## 1.9.34-alpha7 ## 1.9.36
### ⚠️ Upgrading to this build requires reinstalling dependencies A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
The two dependency changes below — dropping `sharp` and pinning `better-sqlite3` — alter ### Fixed — 1.9.35 would not start on a server collecting install statistics
`server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in both directions.
The runbook's rollback step marks that command "only if dependencies changed"; for this release
they did.
- **Upgrading**: `scripts/upgrade.sh` already runs it. A hand-rolled deploy that skips it leaves a A server with install-statistics collection switched on could not start 1.9.35. It threw
`better-sqlite3` that no longer matches `package.json`. `ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
- **Rolling back past this build**: the reinstall is **mandatory**. Earlier builds import `sharp` at and a service manager configured to restart it would do so in a loop.
runtime to thumbnail images, and this build removes it from the production dependencies — so
rolling back the code without reinstalling leaves a server whose image ingest cannot load its
decoder. `lib/preflight-deps.js` catches this at boot and repairs it, but do not rely on that as
the plan.
Docker deployments need no action either way: dependencies are installed inside the image. **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.
No migrations, no configuration changes, no player-side changes. The cause was a reference to the database resolved when the file loaded rather than when the request
arrived, in code that had been moved earlier in the same release.
### Changed — the startup check now covers configuration only one deployment uses
The fault above shipped through a full test suite and every CI job green, because the affected block
is switched on by configuration that no test set. It had never executed anywhere except the one
server that turns it on.
The startup smoke check now boots with that configuration enabled and confirms the routes it adds
actually answer. Code that only one deployment runs is exactly the code an automated check has to
exercise, and it now does.
### Upgrading
No migrations, no configuration changes, and no dependency changes from 1.9.35 — this release only
alters when one value is read. Upgrading from 1.9.34 or earlier, the 1.9.35 note still applies:
`npm ci --omit=dev` is required in both directions, which `scripts/upgrade.sh` already runs.
## 1.9.35
A maintenance release. Two faults where the product was working correctly and still looked broken to
whoever was standing in front of the screen, plus the dependency advisories that could reach a running
server.
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### Fixed — a player could get stuck on an update it was never able to install
A staged update is saved under a filename built from the version the server advertised. If a server
advertised one version while still serving the file for an older one, the player saved the old file
under the new name — and from then on found it, verified its signature, accepted it, and installed
something that changed nothing. The version never moved, so the same update was offered again, and the
player retried the same no-op until it hit its attempt limit.
The signature check passed the whole time, correctly: the file was genuine, it was simply the wrong
one. Worse, fixing the server did not help, because the bad file was reused before anything was
downloaded. Recovery meant deleting the file on the device by hand.
A staged update is now reused only when the version inside the file matches the version being
installed, and a fresh download is checked the same way before it is applied. A server serving the
wrong file now says so — *"server served 1.9.33 but advertised 1.9.34 — the update on the server is
stale"* — and the file is deleted instead of kept. That makes this self-healing: once the server is
corrected, the player recovers on its own.
**Clear update cache** on the device page discards every staged update on a player. The version check
should make it rarely necessary; it exists because a player already holding a bad file predates this
release and cannot benefit from the check, and because the alternative is a cable and a laptop.
### Fixed — directory search showed the system keyboard on top of its own
The directory-search widget draws its own on-screen keyboard, sized and themed to the panel and on by
default. On Android it was never visible. The page puts the cursor in a real text field, which is the
signal for the device to raise its system keyboard — over the bottom of the screen, exactly where the
widget's keyboard is.
So a wall-mounted directory showed the phone keyboard: split across the screen, with microphone, GIF,
emoji and a settings key that opens the keyboard vendor's own interface on a kiosk. On one panel the
only keyboard installed was voice input, so touching the search box opened a microphone. The widget's
own keyboard had been underneath the whole time.
When the widget draws a keyboard, it now tells the device not to raise one. Turn the built-in keyboard
off and the system keyboard behaves as before — with nothing to cover, it is the only way left to type.
### Changed — the dependency advisories that could reach a running server are cleared
Every high-severity advisory affecting a production install is resolved, including eight in the mail
library covering SMTP command injection and header injection. The remaining advisories are in
development-only tooling that is not installed on a server and cannot be reached from one.
The real-time connection to players is deliberately untouched: the fix there was a patch to the message
parser with no change to the format players speak, so nothing about an existing player's connection
changes.
Sending mail was previously covered only by tests that substituted the mail library for a stand-in,
which would have stayed green through any change in the library itself. It is now also tested against
the real one.
### Added — an install that collects statistics can show the total on its landing page
Where install statistics are being collected, the landing page can show how many screens have been
deployed in total. It is an aggregate across every install that chooses to report, so it says nothing
about any single one.
This does nothing on a normal install: the figure is served only where collection is switched on, so a
private server never publishes its own screen count, and the line is hidden entirely rather than
showing a zero.
### Changed — release notes are the written ones
Published release notes now come from this file rather than from a list of commit subjects. The
previous release announced itself as one commit titled "chore(release)" while the entry describing it
sat here unread.
### ⚠️ Upgrading from 1.9.34 reinstalls dependencies
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in
both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
startup where it can reach the npm registry.
Docker deployments need no action; dependencies are installed inside the image.
## 1.9.34
Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what
was there before. Alongside it: the last native image dependency is gone, several players that
could not install updates now can, and an install can optionally report how many screens it runs.
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### Fixed — the old sign-in path could be replayed by any site you had signed into
What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and
then trusted the email address that came back, never asking the only question that matters: *who was
this token issued for?* Any other site a user had signed into — anything holding a token with the
right scope — could replay it against ScreenTinker and receive a session as that user. No password,
no interaction from the victim.
Identity now comes from an **ID token only**, with the signature checked against the provider's
keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider:
Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries
rather than hand-written special cases, which is what removed the two paths that were wrong.
### Added — organizations bring their own single sign-on
Instance-wide providers stay the default and are now unlimited in number. On top of that, an
organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking
OpenID Connect — configured by that organization's own admins in Settings, with no operator
involvement and no restart.
A provider may only assert addresses at domains the organization has **proved it controls**, via a
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to
one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard
zone we do not operate, and it would turn a subdomain takeover into an apex takeover.
An organization's provider never appears publicly. The login page reveals it only after someone
enters an address at a verified domain, so a guessed domain cannot confirm who your customers are.
**Require single sign-on** is available per organization: passwords refused, other providers
refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would
just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the
request, so one compromised org admin cannot quietly reopen password login. Break-glass for a
platform administrator is the correct password and nothing else, and a wrong password returns the
same refusal everyone else gets, so it cannot be used to discover whether an account exists.
⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible
without a reset.
Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other
grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator,
and an organization's own provider is believed once it has verified a domain — the DNS proof stands
in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has
verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone.
Other providers that verify addresses without saying so can opt in with
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
**With no SSO environment variables set, the product behaves exactly as it did before.**
### Added — an existing account can move to single sign-on
Signing in with a provider has always refused to take over an account that already has a password,
and that refusal is right — otherwise anyone who could persuade a provider to assert your address
would inherit your account. But the way out had never been built, so an account created with a
password simply could not use single sign-on.
**Settings → Sign-in method** now offers it, in both directions. An account has exactly **one**
credential: linking **deletes** the password, and the confirmation says so, because a password left
behind is a second way in that you believe you replaced. Unlinking asks for the new password first
and applies both changes together, so the account is never left without a way in.
The account being linked is the one you are **signed in as**, never whichever account matches the
address the provider returns — that is what separates linking from the takeover the login page
refuses. Only providers configured on this server can be linked; an organization's own provider
cannot attach itself to an account.
### Changed — the login page asks who you are before how you sign in
The password box appears once you have entered your address and continued, rather than sitting there
from the start. That is what lets the page check whether your organization uses single sign-on
*before* offering you a credential, so someone whose company requires it is shown that rather than a
password box that was always going to be refused. Correcting your address takes you back a step.
The address is no longer looked up on every keystroke — it answered for half-finished domains,
changed the form under you mid-address, and could exhaust a shared office network's lookup budget
before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so
the page no longer changes shape while you type.
Setup instructions for both operators and organization admins are in
[docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications
— including the one that catches everyone: the Microsoft tenant setting names the directory that
*authenticates the user*, which for personal accounts is not the directory the application is
registered in.
### Changed — image processing no longer needs a native library
Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and
avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and
`better-sqlite3` is the only native module left.
A native module needs a prebuilt binary matching both the platform and the Node version; when there
isn't one the server fails at load with an error that reads like database corruption rather than a
missing image library. That class of failure is gone from this half of the product.
Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif
through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library
in use decodes AV1 but refuses HEVC.
Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a
12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks
an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats
and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with
serving requests.
### Fixed — players that could not install an update
Three separate faults, each able to strand a player on an old version.
**Updates were written to external storage.** Where that location is absent, or exists but cannot be
written to, the download failed the instant it began — before any data arrived — and reported only
that it had failed to download or verify. The same player could be caching content perfectly well
throughout, because content goes to internal storage. Updates now go to the first location that
genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to
it* rather than by asking whether it is writable — the previous check asked, was told yes, and the
write failed anyway.
**Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one
numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved
forward, while the server named the newer build as latest in the same reply. Numbers in version
names are now compared as numbers. The BrightSign host package carried the same comparison and is
fixed with it — there, a wrong answer replaces the script that starts the player.
**A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate
comes from a legacy path that can return nothing. The player now reads the signature itself before
giving up. Verification is unchanged: the certificate is still compared against the installed app,
and anything unsigned, tampered with, or signed by a different key is still rejected.
A failed update now also says which of those things went wrong, instead of one message covering
every possible cause.
⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how
updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by
hand, after which it recovers on its own and stays fixed.
### Fixed — the Android player could leave a band down one edge of the screen ### Fixed — the Android player could leave a band down one edge of the screen
A panel would sometimes not fill its display: content rendered into a stage sized from a window that A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar.
no longer existed, leaving a bar the exact size of the hidden system bar. It was intermittent It was intermittent because it depended on whether the app was measured before or after the system
because it depended on whether the app was measured before or after the system UI was hidden — the UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart.
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.
The stage is now measured from the current window and re-measured when focus changes, so a late
change to the usable area is picked up instead of being baked in at startup.
Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem; 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. pinning the output resolution fixed the corruption, and this fixes the band that remained.
### Added — opt-in install statistics
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
design and should stay that way. A platform administrator is asked, once, whether this install will
share how many screens it runs.
The whole payload is three fields — a random instance ID, the version, and the screen count — and
nothing else: no hostnames, addresses, organization or user names, device names, content or
configuration. Settings shows the **actual payload this server would send**, generated live from its
own data, alongside what it last really sent and when, so the claim can be checked rather than taken
on trust. Turning it on reports immediately, and a blocked outbound connection is named along with
the address to allow, rather than failing silently.
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you
run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who
wants their own numbers and nothing sent to us can set it and leave sharing off.
The random ID exists only so repeat reports from one server count as one server, which makes a
report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in,
any total published from it is a floor, never an estimate of the install base. Full detail in
[docs/telemetry.md](docs/telemetry.md).
### Added — organizations may re-enable same-origin widgets, deliberately
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce
strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a
typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or
admin — a workspace admin is deliberately not enough — and the change is written to the activity
log. Contributed by @ChrisChrome.
The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard
where the admin's session token lives, so honouring the setting there would let anyone who can
author a widget lift the session of whichever admin clicked Preview. The setting exists so
*displays* can embed origin-strict sites; a display holds a device token, an admin's browser does
not.
### Fixed — RSS tickers ran at a speed that depended on how much news there was
Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so
a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read,
and it appeared to jump back to the start. It now holds a constant rate, so more items simply take
proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
### Fixed — user-controlled text is escaped where it reaches the page
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when
switching views, and the main content no longer collapses to a narrow column.
### Added — an operations runbook ### Added — an operations runbook
`docs/operations.md`. How to deploy, verify, and roll back an instance in both shapes it runs in [docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both
(git + systemd, and Docker), including what to back up first, how to tell a deploy actually worked, shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only
and the traps that are only obvious once they have bitten you. 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
### Changed — image processing no longer uses a native module every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor
`sharp` is gone. Thumbnailing and image measurement are pure JavaScript (Jimp) with WebAssembly hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD
codecs for webp and avif, running on a worker thread. `sharp` remains as a development dependency card by discarding writes — safe for a player, quietly destructive for a server whose database is
for test fixtures, so `--omit=dev` excludes it from a deployed install entirely. written continuously.
The motivation is that a native module needs a prebuilt binary matching both the platform and the
Node ABI; when there isn't one, the failure arrives at load time and reads like database corruption
rather than a missing image library. `better-sqlite3` is now the only native module left.
Format support is unchanged in practice. jpeg, png, gif, tiff and bmp decode natively; webp and avif
via WebAssembly. `.heic` still produces no thumbnail — it never did, because the `sharp` builds in
use decode AV1 but refuse HEVC.
Images are decoded on a worker thread rather than in-process. Pure-JavaScript decoding costs about a
second for a 12-megapixel photo, which in-process would block the event loop — and the thumbnail
backfill walks an entire library at boot, which is exactly how a maintenance task turns into missed
heartbeats and players marked offline. Thumbnailing is slower in wall-clock terms than the native
library was, and no longer competes with serving requests.
### Changed — `better-sqlite3` pinned to 12.9.0 ### Changed — `better-sqlite3` pinned to 12.9.0
Preparation for a future Node 22 upgrade, landed separately so the runtime move and the database Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database
driver move stay independently reversible. 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 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 the current and the next Node major; later 12.x releases dropped the older one while still
advertising support for it in `engines`. A caret range would resolve to one of those and silently advertising support for it. A caret range would resolve to one of those and silently turn
turn installation into a from-source compile. `lib/preflight-deps.js` explains this at the point installation into a source build. Nothing in the query API changed.
anyone debugging the resulting failure would be reading.
Nothing in the query API changed — every major since 9.x was bumped only to drop end-of-life Node ### ⚠️ Upgrading from 1.9.33 reinstalls dependencies
and Electron versions. This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** —
in both directions.
## 1.9.34-alpha6 - **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.
### Added — a setup guide for single sign-on Docker deployments need no action either way; dependencies are installed inside the image.
`docs/sso-setup.md`. The README explained what SSO is and listed the settings; it did not say where
to click, which of several plausible values to use, or what a given failure means. The guide walks
both the operator configuring Google or Microsoft for the whole instance and an organization admin
bringing their own provider, and ends with a table of every error the sign-in can produce alongside
what usually causes it.
Written from configuring real Google and Entra applications rather than from the code, so the ### Known limitations
pitfalls in it are the ones that actually cost time — most of all that the Microsoft tenant setting Deliberately unresolved, and worth knowing:
names the directory that *authenticates the user*, which for personal accounts is not the directory
the application is registered in.
## 1.9.34-alpha5 - Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly
### Fixed — the Link button in Settings always said "Authentication required"
`alpha4` shipped account linking with a button that could never work. It navigated the browser
straight at the link endpoint, and this app's session is a token held in the page rather than a
cookie — so the request arrived with no credentials and was refused, every time.
The page now asks the server for the sign-in URL first, using its session, and follows that. Nothing
about the link itself changed.
## 1.9.34-alpha4
Two changes to how signing in works, both found by configuring real Google and Microsoft sign-in
rather than by reading the code.
### 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.
That refusal is right: otherwise anyone who could get a provider to assert your address would inherit
your account. But the way out — sign in with your password, then link from Settings — had never been
built, so it was a dead end. An account created with a password simply could not use single sign-on.
**Settings → Sign-in method** now offers it. An account with a password can link one of the
providers this server offers; an account on a provider can unlink back to a password.
An account has exactly **one** credential. Linking **deletes** the password, and the confirmation
says so plainly, 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, even briefly, with no way to sign in.
The account being linked is the one you are **signed in as** — never whichever account matches the
email the provider returns. That is what separates linking from the takeover the login page refuses.
Only the 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 now appears once you have entered your email address and continued, rather than
sitting there from the start. That is what lets the page ask whether your organization uses single
sign-on *before* offering you a credential: if it does, you are shown that instead of a password box
that was going to be refused. Correcting your address takes you back a step so the answer matches
what you actually typed.
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 IP's lookup budget before anyone
had tried to sign in.
Google and Microsoft buttons now stay visible throughout, including for organizations that require
their own provider. Such a login is still refused by the server; the page no longer changes shape
while you type.
## 1.9.34-alpha3
Completes what `alpha2` half-fixed. That release made the operator's own Microsoft button work and
left customers' Entra tenants broken, which is the wrong way round.
### Fixed — a customer's own Entra tenant was refused after its domain went green
An organization that brings its own Microsoft tenant publishes the DNS record, watches its domain
verify, and was then refused at login with `email_unverified`. Entra sends no such claim, and an
organization's provider was never allowed to assume one.
The refusal fired *after* domain confinement had already passed, so it was not the domain check doing
its job — it was a second check asking for something Microsoft does not emit. Requiring a claim a
provider cannot send is not a security control; it is an outage.
An organization's provider is now believed **once it has verified a domain**. The DNS proof is what
stands in for the claim: whoever controls a domain's DNS controls its mail, which is the same trust
that makes a verification link meaningful. A provider that has verified nothing still assumes
nothing, domain confinement is unchanged, and an explicit `email_verified: false` is still refused
from anyone.
The assumption follows from the proof and is never a stored setting — an organization cannot switch
it on for itself.
## 1.9.34-alpha2
Everything in `1.9.34-alpha1`, plus one fix without which the headline feature could not be used with
Microsoft at all.
### 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.
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.
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.
Other identity providers that verify addresses without saying so in the token can opt in with
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
### 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.
## 1.9.34-alpha1
**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 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.
### 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?*
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 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 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
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.
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.
**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.
With no SSO environment variables set, the product behaves exactly as it did before. That was
verified in a browser, not merely reasoned about.
### 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.
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.
### 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.
### Fixed — user-controlled text is escaped where it actually reaches HTML
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.
### Known limitations in this alpha
Deliberately not resolved yet, and worth knowing before testing against them:
- Enabling SSO-only **clears the passwords** of members at verified domains. That is irreversible
without a reset. without a reset.
- The SSO-only removal queue is an availability dependency on the operator: if nobody approves, the - Turning that requirement back off depends on a platform administrator approving the request; if
organization stays SSO-only. nobody does, the organization stays on single sign-on.
- `landing.html` still interpolates plan names into HTML without escaping. Those values come from - `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. 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. - `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves
Pre-existing, unchanged by this release. 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 ## 1.9.33

View file

@ -1 +1 @@
1.9.34-alpha7 1.9.36

View file

@ -13,8 +13,8 @@ android {
targetSdk = 34 targetSdk = 34
// Env-overridable so device-owner reinstalls (which require an ever-increasing // Env-overridable so device-owner reinstalls (which require an ever-increasing
// versionCode — downgrades are blocked) don't churn this file each build. // versionCode — downgrades are blocked) don't churn this file each build.
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "113").toInt() versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "123").toInt()
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.34-alpha7" versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.36"
} }
signingConfigs { signingConfigs {
@ -87,8 +87,24 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.2.1") implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1") implementation("androidx.media3:media3-ui:1.2.1")
// Socket.IO client // Socket.IO client.
implementation("io.socket:socket.io-client:2.1.0") //
// 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 // WorkManager for background downloads
implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.work:work-runtime-ktx:2.9.0")

View file

@ -941,6 +941,14 @@ class MainActivity : AppCompatActivity() {
if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true) if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true)
} }
// #161 device-owner tooling: push + silently install an arbitrary APK from a URL. // #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" -> { "install_apk" -> {
val url = payload?.optString("url", "") ?: "" val url = payload?.optString("url", "") ?: ""
if (url.isNotBlank() && ::updateChecker.isInitialized) { if (url.isNotBlank() && ::updateChecker.isInitialized) {

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. // class is the imperative shell that persists state and does the download/install.
var otaLogReporter: ((level: String, message: String) -> Unit)? = null 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) { private fun report(level: String, message: String) {
when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) } when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) }
try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {} 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, // 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 // 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. // 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 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 // 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 // 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 // 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 { private fun downloadAndInstall(url: String, version: String): Boolean {
try { try {
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), // Find somewhere that will actually take the file, before asking the network for it.
"ScreenTinker-$version.apk") 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 // #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 // 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. // 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)") Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
handler.post { installApk(apkFile) } handler.post { installApk(apkFile) }
return true return true
@ -332,6 +428,7 @@ class UpdateChecker(private val context: Context) {
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
lastFailure = "server returned HTTP ${response.code} for the APK"
Log.e(TAG, "Download failed: ${response.code}") Log.e(TAG, "Download failed: ${response.code}")
return false 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 // 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 // the currently-installed app before installing. An attacker can't forge
// our signature, so this holds even over an untrusted transport. // 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)) { 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)") Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
apkFile.delete() apkFile.delete()
return false return false
@ -364,6 +475,7 @@ class UpdateChecker(private val context: Context) {
} }
return true return true
} catch (e: Exception) { } catch (e: Exception) {
lastFailure = "download/install threw ${e.javaClass.simpleName}: ${e.message}"
Log.e(TAG, "Download/install error: ${e.message}") Log.e(TAG, "Download/install error: ${e.message}")
return false return false
} }
@ -378,7 +490,9 @@ class UpdateChecker(private val context: Context) {
try { try {
val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" } val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" }
val fileName = "pushed-" + (if (base.endsWith(".apk")) base else "$base.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() if (apkFile.exists()) apkFile.delete()
val response = client.newCall(Request.Builder().url(url).build()).execute() val response = client.newCall(Request.Builder().url(url).build()).execute()
if (!response.isSuccessful) { Log.e(TAG, "installFromUrl: download failed ${response.code}"); return@Thread } 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 // True only if the downloaded APK is this same package and shares a signing
// certificate with the installed app. Fail-closed on any error. // 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 { private fun verifyApkSignature(apkFile: File): Boolean {
return try { return try {
val pm = context.packageManager val pm = context.packageManager
@ -498,10 +658,12 @@ class UpdateChecker(private val context: Context) {
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags) val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags)
if (downloaded == null) { 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") Log.e(TAG, "Could not parse downloaded APK")
return false return false
} }
if (downloaded.packageName != context.packageName) { if (downloaded.packageName != context.packageName) {
lastFailure = "APK is package ${downloaded.packageName}, expected ${context.packageName}"
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}") Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
return false return false
} }
@ -511,18 +673,37 @@ class UpdateChecker(private val context: Context) {
val installedFlags = if (installedUsesSigningInfo) val installedFlags = if (installedUsesSigningInfo)
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
val installed = pm.getPackageInfo(context.packageName, installedFlags) 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) val installedSigs = signingCertHashes(installed, installedUsesSigningInfo)
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) { 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})") Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
return false return false
} }
// Require a non-empty overlap of signer certs (handles multi-signer / cert-rotation // 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). // the same way the API>=30 path does: compare the full current signer sets).
val match = downloadedSigs.any { it in installedSigs } 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 match
} catch (e: Exception) { } catch (e: Exception) {
lastFailure = "signature check threw ${e.javaClass.simpleName}: ${e.message}"
Log.e(TAG, "Signature verification error: ${e.message}", e) Log.e(TAG, "Signature verification error: ${e.message}", e)
false false
} }
@ -533,6 +714,31 @@ class UpdateChecker(private val context: Context) {
// multi-signer + rotation aware), GET_SIGNATURES -> legacy .signatures (the only field // 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 // 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. // 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> { private fun signingCertHashes(info: PackageInfo, useSigningInfo: Boolean): Set<String> {
val sigs: Array<Signature>? = if (useSigningInfo) { val sigs: Array<Signature>? = if (useSigningInfo) {
info.signingInfo?.apkContentsSigners info.signingInfo?.apkContentsSigners

View file

@ -2,4 +2,11 @@
<paths> <paths>
<external-files-path name="downloads" path="Download/" /> <external-files-path name="downloads" path="Download/" />
<external-files-path name="apk" path="." /> <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> </paths>

45
android/licenses.json Normal file
View file

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

103
docs/licensing.md Normal file
View file

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

View file

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

View file

@ -332,3 +332,25 @@ an identity provider and that provider later fails, the login page cannot help y
**Backups are only real once restored.** A snapshot that has never been restored is a hypothesis. **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 Periodically restore the newest one into a throwaway instance and confirm it boots and serves
`/api/status`. `/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.

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

@ -266,6 +266,10 @@ export const api = {
// #146: toggle the /api/status debug block exposure (platform-admin only). // #146: toggle the /api/status debug block exposure (platform-admin only).
adminGetStatusDebug: () => request('/admin/status-debug'), adminGetStatusDebug: () => request('/admin/status-debug'),
adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }), 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). // Per-user workspace membership management (platform Users page modal).
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`), adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),

View file

@ -683,6 +683,8 @@ export default {
'device.ctl.screen_on': 'Screen On', 'device.ctl.screen_on': 'Screen On',
'device.ctl.launch_player': 'Launch Player', 'device.ctl.launch_player': 'Launch Player',
'device.ctl.force_update': 'Force Update', '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', 'device.ctl.shutdown': 'Shutdown',
// Remote tab // Remote tab
'device.remote.start_prompt': 'Click "Start Remote" to begin', 'device.remote.start_prompt': 'Click "Start Remote" to begin',
@ -773,6 +775,7 @@ export default {
'device.toast.screen_on_sent': 'Screen on command sent', 'device.toast.screen_on_sent': 'Screen on command sent',
'device.toast.launch_sent': 'Launch command sent', 'device.toast.launch_sent': 'Launch command sent',
'device.toast.update_triggered': 'Update check triggered', '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.remote_started': 'Remote session started',
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect', 'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable', 'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',

View file

@ -1,7 +1,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js'; import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.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 { t, tn } from '../i18n.js';
import * as gettingStarted from '../components/getting-started.js'; import * as gettingStarted from '../components/getting-started.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.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) { export function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
@ -432,6 +473,10 @@ export function render(container) {
// Load everything // Load everything
loadDashboard(); 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 // Real-time updates
statusHandler = (data) => { statusHandler = (data) => {
const b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text 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"/> <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> </svg>
${t('device.ctl.force_update')} ${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>` : ''} </button>` : ''}
${can('system.reboot') ? ` ${can('system.reboot') ? `
<button class="btn btn-danger btn-sm" id="shutdownBtn"> <button class="btn btn-danger btn-sm" id="shutdownBtn">
@ -1593,6 +1599,12 @@ function setupActions(device) {
sendWithFeedback('update', 'Update', 'device.toast.update_triggered'); 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). // #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
document.getElementById('sendPipBtn')?.addEventListener('click', async () => { document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
const uri = (document.getElementById('pipUri')?.value || '').trim(); const uri = (document.getElementById('pipUri')?.value || '').trim();

View file

@ -169,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 id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
</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>` : ''} ${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"> <div class="settings-section">
@ -275,6 +282,7 @@ export async function render(container) {
if (isAdmin) { if (isAdmin) {
loadUsers(); loadUsers();
loadWhiteLabel(); loadWhiteLabel();
loadTelemetry();
// Support token generator // Support token generator
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => { document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
@ -547,6 +555,87 @@ export async function render(container) {
* Only instance-wide providers appear. An organization's provider is chosen by a customer and * 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. * 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() { async function loadSsoLink() {
const block = document.getElementById('ssoLinkBlock'); const block = document.getElementById('ssoLinkBlock');
if (!block) return; if (!block) return;

View file

@ -210,6 +210,14 @@
</a> </a>
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a> <a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
</div> </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> </section>
<!-- Intro video --> <!-- Intro video -->
@ -630,6 +638,19 @@
// replace it on the public marketing page with a hardcoded Contact Us // replace it on the public marketing page with a hardcoded Contact Us
// card. Other consumers of /api/subscription/plans (billing.js, // card. Other consumers of /api/subscription/plans (billing.js,
// settings.js, admin.js) get the full list as before. // 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 => { fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
const grid = document.getElementById('pricingGrid'); const grid = document.getElementById('pricingGrid');
const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise'); 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 from npm) so self-hosted / air-gapped instances work with no external dependency and no
build step. 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 ## redoc.standalone.js
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`. - **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
- **Version:** 2.3.9 - **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 - **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. - **Why committed:** the API reference must render on offline instances — no CDN, no build step.
- **Regenerate / update:** - **Regenerate / update:**

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

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

View file

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

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

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

View file

@ -641,6 +641,19 @@ const migrations = [
// it can do nothing and must be respected. // it can do nothing and must be respected.
'ALTER TABLE devices ADD COLUMN capabilities TEXT', '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" // Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign. // error means the column is already present (expected on a migrated DB) - benign.

View file

@ -1,5 +1,7 @@
'use strict'; 'use strict';
const { preCmp } = require('./version-precedence');
/* /*
* Should a BrightSign player replace its own host package (autorun.zip)? * 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 === B.pre) return 0;
if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1 if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1
if (B.pre === null) return -1; 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

@ -41,6 +41,8 @@ const { rollingCounter, bump, read } = require('./rolling-counter');
const rateBackoffCtr = rollingCounter(); const rateBackoffCtr = rollingCounter();
// --- minimal semver-ish parse/compare (no dependency) --- // --- minimal semver-ish parse/compare (no dependency) ---
const { preCmp } = require('./version-precedence');
function parseVer(v) { function parseVer(v) {
if (typeof v !== 'string') return null; if (typeof v !== 'string') return null;
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim()); 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 === b.pre) return 0;
if (a.pre === null) return 1; // release outranks a prerelease of the same core if (a.pre === null) return 1; // release outranks a prerelease of the same core
if (b.pre === null) return -1; if (b.pre === null) return -1;
// lexical prerelease compare — fine for beta1..beta9 (cores decide everything else). // Natural prerelease compare: digit runs numerically, so alpha8 < alpha9 < alpha10 < alpha11.
return a.pre < b.pre ? -1 : (a.pre > b.pre ? 1 : 0); // 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); } 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', launch: 'system.restart_player',
refresh: 'system.restart_player', refresh: 'system.restart_player',
update: 'system.self_update', 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 // display
screen_on: 'display.power', screen_on: 'display.power',

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

View file

@ -1,12 +1,13 @@
{ {
"name": "screentinker", "name": "screentinker",
"version": "1.9.34-alpha7", "version": "1.9.36",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "screentinker", "name": "screentinker",
"version": "1.9.34-alpha7", "version": "1.9.36",
"license": "MIT",
"dependencies": { "dependencies": {
"@azure/msal-node": "^5.2.1", "@azure/msal-node": "^5.2.1",
"@jsquash/avif": "^1.3.0", "@jsquash/avif": "^1.3.0",
@ -21,7 +22,7 @@
"jimp": "^1.6.1", "jimp": "^1.6.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16", "nodemailer": "^9.0.5",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"socket.io": "^4.7.2", "socket.io": "^4.7.2",
@ -1941,9 +1942,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.2", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@ -3434,9 +3435,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.2.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@ -3544,9 +3545,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3913,9 +3914,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "6.10.1", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@ -4931,9 +4932,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/socket.io-parser": { "node_modules/socket.io-parser": {
"version": "4.2.6", "version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@socket.io/component-emitter": "~3.1.0", "@socket.io/component-emitter": "~3.1.0",

View file

@ -1,6 +1,7 @@
{ {
"name": "screentinker", "name": "screentinker",
"version": "1.9.34-alpha7", "version": "1.9.36",
"license": "MIT",
"description": "ScreenTinker - Digital Signage Management Server", "description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -23,7 +24,7 @@
"jimp": "^1.6.1", "jimp": "^1.6.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16", "nodemailer": "^9.0.5",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"socket.io": "^4.7.2", "socket.io": "^4.7.2",

View file

@ -426,6 +426,50 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
res.json({ enabled }); 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 ===================== // ===================== Version update indicator =====================
// check-update = requireAdmin — a read-only GHCR poll, operational. // check-update = requireAdmin — a read-only GHCR poll, operational.
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the // trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the

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) ----- // ----- on-screen keyboard (drives the same filter path as typing) -----
if (cfg.show_onscreen_keyboard) { 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'); var kb = document.getElementById('keyboard');
function press(ch) { input.value += ch; try { input.focus(); } catch(e){} onInput(); } function press(ch) { input.value += ch; try { input.focus(); } catch(e){} onInput(); }
['1234567890','qwertyuiop','asdfghjkl','zxcvbnm'].forEach(function(r){ ['1234567890','qwertyuiop','asdfghjkl','zxcvbnm'].forEach(function(r){

View file

@ -977,6 +977,22 @@ app.get('/api/version', (req, res) => {
// Public status page // Public status page
app.use('/api/status', require('./routes/status')); 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 // #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: // 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 // 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'); const { startAlertService } = require('./services/alerts');
startAlertService(io); startAlertService(io);
// Start activation-nudge sweep (T+3 onboarding nudge; gated on HOSTED_INSTANCE) // Start activation-nudge sweep (T+3 onboarding nudge; gated on HOSTED_INSTANCE)
const { startActivationNudge } = require('./services/activationNudge'); const { startActivationNudge } = require('./services/activationNudge');
startActivationNudge(); startActivationNudge();
@ -1248,6 +1265,13 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Handle provisioning via WebSocket notification // Handle provisioning via WebSocket notification
const { db } = require('./db/database'); 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'); const originalProvisionRoute = require('./routes/provisioning');
// #161: device-owner QR provisioning. Returns the AOSP provisioning payload (DPC component + APK // #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)'); 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 () => { test('missing source -> friendly fallback page, not a 500', async () => {
seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' }); seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
const { status, html } = await fetchRender('search_missing'); 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

@ -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"?> <?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets" <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:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
<tizen:profile name="tv"/> <tizen:profile name="tv"/>
<name>ScreenTinker</name> <name>ScreenTinker</name>