Compare commits

...

20 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
29 changed files with 1429 additions and 404 deletions

View file

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

View file

@ -85,6 +85,15 @@ jobs:
./scripts/build-autorun-zip.sh -o autorun.zip
ls -la autorun.zip
# A published SBOM is what turns "we track licences" into something a customer or an
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
- name: Generate SBOM (production dependencies)
run: |
( cd server && npm ci --omit=dev )
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
ls -la screentinker-sbom-*.cdx.json
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
run: |
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
@ -100,15 +109,42 @@ jobs:
- name: Generate release notes
run: |
PREV="${{ steps.ver.outputs.prev }}"
VERSION="${{ steps.ver.outputs.version }}"
# Prefer the hand-written CHANGELOG section for this version.
#
# The generated list is commit SUBJECTS, which describe the work, not the release: cutting
# 1.9.34 produced a page reading "chore(release): v1.9.34" and one changelog commit, while
# the entry describing single sign-on, the native-dependency removal, the update fixes and
# every outside contributor sat in CHANGELOG.md and was never published. The notes on the
# release page are what most people actually read, so they should be the written ones.
#
# awk rather than sed: the body contains regex metacharacters and markdown that a sed range
# would mangle. This takes everything between `## <version>` and the next `## ` heading.
CHANGELOG_BODY="$(awk -v v="## $VERSION" '
$0 == v {found=1; next}
found && /^## / {exit}
found {print}
' CHANGELOG.md)"
{
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
echo "$CHANGELOG_BODY"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
# No entry for this version — fall back to commit subjects rather than publish a
# release with no notes at all. scripts/bump-version.sh already warns when the
# CHANGELOG has no matching heading; this is the same gap showing up downstream.
echo "_No CHANGELOG entry for $VERSION; listing commits instead._"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
fi
fi
echo
echo "### Artifacts"
@ -127,6 +163,7 @@ jobs:
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
} > RELEASE_NOTES.md
cat RELEASE_NOTES.md
@ -144,6 +181,7 @@ jobs:
--notes-file RELEASE_NOTES.md \
"${TARBALL}" \
autorun.zip \
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
tizen/ScreenTinker.wgt
docker:

4
.gitignore vendored
View file

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

View file

@ -1,371 +1,392 @@
# Changelog
## 1.9.34-alpha12
## 1.9.36
### Fixed — updates were refused as "already newer" from alpha10 onward
The update check compared the prerelease part of a version as plain text, so "alpha11" sorted below
"alpha8" — because "1" comes before "8". Every build from alpha10 on was therefore treated as older
than alpha8 and alpha9, and the server refused to offer it while naming it as the latest version in
the same reply. A player on those builds could not be moved forward at all, and nothing about the
failure pointed at version ordering.
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
Prerelease numbers are now compared as numbers. Ordinary precedence is unchanged: beta still comes
after alpha, rc after beta, and a finished release still beats any prerelease of the same version.
### Fixed — 1.9.35 would not start on a server collecting install statistics
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 server with install-statistics collection switched on could not start 1.9.35. It threw
`ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
and a service manager configured to restart it would do so in a loop.
## 1.9.34-alpha11
**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.
### Fixed — an update that will not install now says why
A panel that failed to update reported only "failed to download or failed signature verification" —
one sentence covering seven distinct causes, three of which are download failures where
verification never runs. Every specific reason went to logcat, which an unprivileged app cannot
read on Android 9, so in the field the message named a symptom shared by unrelated problems.
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.
The player now reports the actual cause: the HTTP status the server returned, how many bytes
arrived, whether the signing certificates could be read at all, whether the key genuinely differs,
or that the download could not be written and where.
### Changed — the startup check now covers configuration only one deployment uses
### Fixed — the destination is checked before downloading
Nothing verified that the player could actually store the file. A directory path can be returned
and still be missing, unwritable, on a volume that has gone away, or full — and all of those
surfaced as the same opaque failure as a network fault. The player now proves the directory is
writable (by writing to it, not by asking) and that there is room for the APK plus the copy the
installer stages, before it starts.
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.
### Fixed — a readable APK is no longer refused on Android 9 and 10
On those versions the archive's signing certificate comes from a legacy path that can return
nothing, and the update was then refused with no way to distinguish that from a real mismatch. 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.
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.
## 1.9.34-alpha10
### Upgrading
### Changed — install statistics report as soon as you opt in, and say when they cannot
Turning sharing on now sends a report immediately rather than waiting for the next daily tick, and
a failed attempt is recorded and explained. A server whose outbound traffic is filtered previously
looked identical to one where the feature was broken: sharing on, nothing arriving, no way to tell
which. Settings now names the address that did not answer and why, and a later success clears the
warning.
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.
### Added — keep your own copy of the statistics
`TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you run.
## 1.9.35
It is **additional, not a redirect** — the shared report still goes to ScreenTinker, which is why
it is named EXTRA rather than ENDPOINT, and Settings lists every destination a report goes to. It
is also independent of the sharing switch, because it is your server posting to your host: if you
want your own statistics and nothing sent to us, set it and leave sharing off. Each destination is
attempted separately, so one being unreachable never stops the other.
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.
## 1.9.34-alpha9
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### 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 now asked, once, whether this install
will share how many screens it runs.
### Fixed — a player could get stuck on an update it was never able to install
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 → Install statistics 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. Full detail in [docs/telemetry.md](docs/telemetry.md).
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.
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
return after an update.
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.
The random ID exists only so repeat reports from one server count as one server. It makes a report
pseudonymous rather than anonymous, which the wording says plainly. Because sharing is opt-in, any
total published from this is a floor — "at least N screens" — never an estimate of the install base.
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.
## 1.9.34-alpha8
**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 — an APK download with no external storage went nowhere, silently
A panel could not install an update by OTA **or** by the dashboard's "Push an APK" button. Both
staged the download in `getExternalFilesDir()`, which returns null when external storage is
unavailable — not exotic on signage hardware: no emulated volume, a vendor ROM that never mounts
one, an ejected card, storage still unmounted early in boot. A null parent silently produces a
*relative* path, so the download was written to the process working directory, which is not
writable. The write failed and the panel reported only "failed to download or failed signature
verification".
### Fixed — directory search showed the system keyboard on top of its own
Every signal pointed away from the cause: the HTTP request succeeds (the server records a served
download at the exact second of each failure), the signing key is fine, and because nothing is ever
written there is no partial file to find. It also never recovers — every attempt fails the same way.
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.
Downloads now fall back to internal storage, which cannot be unmounted.
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.
⚠️ **This cannot repair a panel already affected.** The broken download path is the delivery
mechanism, and the Push APK button shared the bug, so a panel in this state needs one manual install
to escape it. The release protects panels that are not yet affected.
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.
## 1.9.34-alpha7
### Changed — the dependency advisories that could reach a running server are cleared
### ⚠️ Upgrading to this build requires reinstalling dependencies
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 two dependency changes below — dropping `sharp` and pinning `better-sqlite3` — alter
`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.
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.
- **Upgrading**: `scripts/upgrade.sh` already runs it. A hand-rolled deploy that skips it leaves a
`better-sqlite3` that no longer matches `package.json`.
- **Rolling back past this build**: the reinstall is **mandatory**. Earlier builds import `sharp` at
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.
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.
Docker deployments need no action either way: dependencies are installed inside the image.
### Added — an install that collects statistics can show the total on its landing page
No migrations, no configuration changes, no player-side changes.
Where install statistics are being collected, the landing page can show how many screens have been
deployed in total. It is an aggregate across every install that chooses to report, so it says nothing
about any single one.
This does nothing on a normal install: the figure is served only where collection is switched on, so a
private server never publishes its own screen count, and the line is hidden entirely rather than
showing a zero.
### Changed — release notes are the written ones
Published release notes now come from this file rather than from a list of commit subjects. The
previous release announced itself as one commit titled "chore(release)" while the entry describing it
sat here unread.
### ⚠️ Upgrading from 1.9.34 reinstalls dependencies
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in
both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
startup where it can reach the npm registry.
Docker deployments need no action; dependencies are installed inside the image.
## 1.9.34
Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what
was there before. Alongside it: the last native image dependency is gone, several players that
could not install updates now can, and an install can optionally report how many screens it runs.
No migrations and no configuration changes. See the upgrade note at the end of this entry.
### Fixed — the old sign-in path could be replayed by any site you had signed into
What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and
then trusted the email address that came back, never asking the only question that matters: *who was
this token issued for?* Any other site a user had signed into — anything holding a token with the
right scope — could replay it against ScreenTinker and receive a session as that user. No password,
no interaction from the victim.
Identity now comes from an **ID token only**, with the signature checked against the provider's
keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider:
Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries
rather than hand-written special cases, which is what removed the two paths that were wrong.
### Added — organizations bring their own single sign-on
Instance-wide providers stay the default and are now unlimited in number. On top of that, an
organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking
OpenID Connect — configured by that organization's own admins in Settings, with no operator
involvement and no restart.
A provider may only assert addresses at domains the organization has **proved it controls**, via a
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to
one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard
zone we do not operate, and it would turn a subdomain takeover into an apex takeover.
An organization's provider never appears publicly. The login page reveals it only after someone
enters an address at a verified domain, so a guessed domain cannot confirm who your customers are.
**Require single sign-on** is available per organization: passwords refused, other providers
refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would
just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the
request, so one compromised org admin cannot quietly reopen password login. Break-glass for a
platform administrator is the correct password and nothing else, and a wrong password returns the
same refusal everyone else gets, so it cannot be used to discover whether an account exists.
⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible
without a reset.
Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other
grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator,
and an organization's own provider is believed once it has verified a domain — the DNS proof stands
in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has
verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone.
Other providers that verify addresses without saying so can opt in with
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
**With no SSO environment variables set, the product behaves exactly as it did before.**
### Added — an existing account can move to single sign-on
Signing in with a provider has always refused to take over an account that already has a password,
and that refusal is right — otherwise anyone who could persuade a provider to assert your address
would inherit your account. But the way out had never been built, so an account created with a
password simply could not use single sign-on.
**Settings → Sign-in method** now offers it, in both directions. An account has exactly **one**
credential: linking **deletes** the password, and the confirmation says so, because a password left
behind is a second way in that you believe you replaced. Unlinking asks for the new password first
and applies both changes together, so the account is never left without a way in.
The account being linked is the one you are **signed in as**, never whichever account matches the
address the provider returns — that is what separates linking from the takeover the login page
refuses. Only providers configured on this server can be linked; an organization's own provider
cannot attach itself to an account.
### Changed — the login page asks who you are before how you sign in
The password box appears once you have entered your address and continued, rather than sitting there
from the start. That is what lets the page check whether your organization uses single sign-on
*before* offering you a credential, so someone whose company requires it is shown that rather than a
password box that was always going to be refused. Correcting your address takes you back a step.
The address is no longer looked up on every keystroke — it answered for half-finished domains,
changed the form under you mid-address, and could exhaust a shared office network's lookup budget
before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so
the page no longer changes shape while you type.
Setup instructions for both operators and organization admins are in
[docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications
— including the one that catches everyone: the Microsoft tenant setting names the directory that
*authenticates the user*, which for personal accounts is not the directory the application is
registered in.
### Changed — image processing no longer needs a native library
Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and
avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and
`better-sqlite3` is the only native module left.
A native module needs a prebuilt binary matching both the platform and the Node version; when there
isn't one the server fails at load with an error that reads like database corruption rather than a
missing image library. That class of failure is gone from this half of the product.
Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif
through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library
in use decodes AV1 but refuses HEVC.
Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a
12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks
an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats
and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with
serving requests.
### Fixed — players that could not install an update
Three separate faults, each able to strand a player on an old version.
**Updates were written to external storage.** Where that location is absent, or exists but cannot be
written to, the download failed the instant it began — before any data arrived — and reported only
that it had failed to download or verify. The same player could be caching content perfectly well
throughout, because content goes to internal storage. Updates now go to the first location that
genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to
it* rather than by asking whether it is writable — the previous check asked, was told yes, and the
write failed anyway.
**Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one
numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved
forward, while the server named the newer build as latest in the same reply. Numbers in version
names are now compared as numbers. The BrightSign host package carried the same comparison and is
fixed with it — there, a wrong answer replaces the script that starts the player.
**A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate
comes from a legacy path that can return nothing. The player now reads the signature itself before
giving up. Verification is unchanged: the certificate is still compared against the installed app,
and anything unsigned, tampered with, or signed by a different key is still rejected.
A failed update now also says which of those things went wrong, instead of one message covering
every possible cause.
⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how
updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by
hand, after which it recovers on its own and stays fixed.
### Fixed — the Android player could leave a band down one edge of the screen
A panel would sometimes not fill its display: content rendered into a stage sized from a window that
no longer existed, leaving a bar the exact size of the hidden system bar. It was intermittent
because it depended on whether the app was measured before or after the system UI was hidden — the
same screen could come up correct after a reboot and wrong after an app restart.
The stage is now measured from the current window and re-measured when focus changes, so a late
change to the usable area is picked up instead of being baked in at startup.
A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar.
It was intermittent because it depended on whether the app was measured before or after the system
UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart.
The stage is now measured from the current window and re-measured when focus changes.
Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem;
pinning the output resolution fixed the corruption, and this fixes the band that remained.
### Added — opt-in install statistics
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
design and should stay that way. A platform administrator is asked, once, whether this install will
share how many screens it runs.
The whole payload is three fields — a random instance ID, the version, and the screen count — and
nothing else: no hostnames, addresses, organization or user names, device names, content or
configuration. Settings shows the **actual payload this server would send**, generated live from its
own data, alongside what it last really sent and when, so the claim can be checked rather than taken
on trust. Turning it on reports immediately, and a blocked outbound connection is named along with
the address to allow, rather than failing silently.
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you
run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who
wants their own numbers and nothing sent to us can set it and leave sharing off.
The random ID exists only so repeat reports from one server count as one server, which makes a
report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in,
any total published from it is a floor, never an estimate of the install base. Full detail in
[docs/telemetry.md](docs/telemetry.md).
### Added — organizations may re-enable same-origin widgets, deliberately
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce
strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a
typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or
admin — a workspace admin is deliberately not enough — and the change is written to the activity
log. Contributed by @ChrisChrome.
The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard
where the admin's session token lives, so honouring the setting there would let anyone who can
author a widget lift the session of whichever admin clicked Preview. The setting exists so
*displays* can embed origin-strict sites; a display holds a device token, an admin's browser does
not.
### Fixed — RSS tickers ran at a speed that depended on how much news there was
Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so
a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read,
and it appeared to jump back to the start. It now holds a constant rate, so more items simply take
proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
### Fixed — user-controlled text is escaped where it reaches the page
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when
switching views, and the main content no longer collapses to a narrow column.
### Added — an operations runbook
`docs/operations.md`. How to deploy, verify, and roll back an instance in both shapes it runs in
(git + systemd, and Docker), including what to back up first, how to tell a deploy actually worked,
and the traps that are only obvious once they have bitten you.
### Changed — image processing no longer uses a native module
`sharp` is gone. Thumbnailing and image measurement are pure JavaScript (Jimp) with WebAssembly
codecs for webp and avif, running on a worker thread. `sharp` remains as a development dependency
for test fixtures, so `--omit=dev` excludes it from a deployed install entirely.
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.
[docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both
shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only
obvious once they have bitten you — including three from a Raspberry Pi 5 report, two of which are
not Pi-specific. A piped installer cannot really ask you anything, because the pipe is its input and
every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor
hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD
card by discarding writes — safe for a player, quietly destructive for a server whose database is
written continuously.
### Changed — `better-sqlite3` pinned to 12.9.0
Preparation for a future Node 22 upgrade, landed separately so the runtime move and the database
driver move stay independently reversible.
Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database
driver can move independently rather than as one flag day.
The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both
the current and the next Node major; later 12.x releases dropped the older one while still
advertising support for it in `engines`. A caret range would resolve to one of those and silently
turn installation into a from-source compile. `lib/preflight-deps.js` explains this at the point
anyone debugging the resulting failure would be reading.
advertising support for it. A caret range would resolve to one of those and silently turn
installation into a source build. Nothing in the query API changed.
Nothing in the query API changed — every major since 9.x was bumped only to drop end-of-life Node
and Electron versions.
### ⚠️ Upgrading from 1.9.33 reinstalls dependencies
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** —
in both directions.
## 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
`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.
Docker deployments need no action either way; dependencies are installed inside the image.
Written from configuring real Google and Entra applications rather than from the code, so the
pitfalls in it are the ones that actually cost time — most of all that the Microsoft tenant setting
names the directory that *authenticates the user*, which for personal accounts is not the directory
the application is registered in.
### Known limitations
Deliberately unresolved, and worth knowing:
## 1.9.34-alpha5
### 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
- Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly
without a reset.
- The SSO-only removal queue is an availability dependency on the operator: if nobody approves, the
organization stays SSO-only.
- Turning that requirement back off depends on a platform administrator approving the request; if
nobody does, the organization stays on single sign-on.
- `landing.html` still interpolates plan names into HTML without escaping. Those values come from
the plans table rather than from end users, so it is a loose end rather than an exposure.
- `/api/provision` is limited to 5/min, so a twenty-display install day takes four minutes of waiting.
Pre-existing, unchanged by this release.
- `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves
some waiting. Pre-existing and unchanged by this release.
### Thanks
This release — and a good deal of what came before it — exists because people outside the project
reported problems and sent patches. Credit was recorded inconsistently at the time, so it is
collected here rather than left scattered.
**Code contributed**
- **@ChrisChrome** — the organization-level widget sandbox toggle (#254) and the RSS ticker rate fix,
both in this release. Earlier: the Debian player/server install script (#137) and web player
auto-connect (#6).
- **@BlazzzPlay** — eight merged pull requests across 1.9.4 to 1.9.13: server-side preview sessions
to work around CSP (#151), the Android hidden settings menu (#152), sending device identity on
reconnect before pairing (#164), the dashboard version indicator and update check (#165, #181),
authenticated thumbnail loading (#182), the server URL in the Add Display modal and the Releases
link on the APK download page (#210), and uploads respecting the current folder (#211).
- **@a10kiloham** — boot-time thumbnail healing with ffmpeg diagnostics and packaging (#244), the
screenshot-request verdict toast and the reverse-proxy header pitfall it documented (#243), and a
configurable maximum upload size (#233).
- **@albanobattistella** — the Italian translation, and its updates since (#2, #145, #232).
**Reported**
- **@carloblu74** — the Raspberry Pi 5 report behind #245, which found five defects in the installer
and kiosk launcher that nothing in this repository would have caught, because nothing here had ever
executed those scripts on a Pi. The runbook notes above come from it.
- **@bold-media-group** — by a wide margin the largest source of field reports, across roughly fifty
issues: the OTA rollout and version-advertising problems, event-loop lag under long uptime, video
wall behaviour, Tizen playback regressions, and the content-loading failures that led to resumable
downloads.
- **@Smiley-k**, **@Semetra22**, **@patrickfinardi09**, **@hapishyguy**, **@Nikhil12656**,
**@gittyguy92** and **@Obe-BoldMediaGroup** — bug reports and feature requests across the 1.9.x
line, including SMTP transport, playlist item scheduling, and the Android playlist-order fault
behind #234.
Several of the hardest faults this year were found by someone running the product on hardware the
project does not own. That is worth saying plainly.
## 1.9.33

View file

@ -1 +1 @@
1.9.34-alpha12
1.9.36

View file

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

View file

@ -941,6 +941,14 @@ class MainActivity : AppCompatActivity() {
if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true)
}
// #161 device-owner tooling: push + silently install an arbitrary APK from a URL.
// Escape hatch for a panel holding a stale/bad staged APK: drop every cached file
// so the next check downloads afresh. Only ever deletes caches.
"clear_update_cache" -> {
if (::updateChecker.isInitialized) {
val n = updateChecker.clearUpdateCache()
Log.i("MainActivity", "clear_update_cache removed $n file(s)")
}
}
"install_apk" -> {
val url = payload?.optString("url", "") ?: ""
if (url.isNotBlank() && ::updateChecker.isInitialized) {

View file

@ -343,26 +343,43 @@ class UpdateChecker(private val context: Context) {
* 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.
*/
private fun apkDir(): File {
// Non-null is not the same as usable. A returned path can still be missing, unwritable, or
// on a volume that has since gone away — and every one of those produced the same opaque
// "failed to download" as a genuine network fault, which is what made this expensive to
// diagnose. Prove the directory before choosing it, and fall back if it does not hold up.
val ext = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
if (ext != null && (ext.exists() || ext.mkdirs()) && ext.canWrite()) return ext
if (ext != null) Log.w(TAG, "External APK dir unusable (exists=${ext.exists()} writable=${ext.canWrite()}) — using internal")
else Log.w(TAG, "External storage unavailable — staging APKs in internal storage instead")
return File(context.filesDir, "Download").apply { mkdirs() }
/*
* 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()
}
/*
* Can we actually put a ~9MB file here, right now?
*
* Checked BEFORE the download rather than discovered as an exception during the write, so the
* failure names the real condition a read-only volume, a missing directory, a full disk
* instead of surfacing as a generic IOException three layers up. Returns null when fine, or the
* reason it is not.
*/
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"
@ -386,13 +403,11 @@ class UpdateChecker(private val context: Context) {
private fun downloadAndInstall(url: String, version: String): Boolean {
try {
val dir = apkDir()
// Preflight the destination. If the box cannot hold the file, say THAT — rather than
// letting it surface later as a truncated write or a generic IOException, which is
// indistinguishable from a network problem in the message the operator sees.
apkDirProblem(dir, 9L * 1024 * 1024)?.let {
lastFailure = "cannot stage the update — $it"
Log.e(TAG, "APK staging unavailable: $it")
// Find somewhere that will actually take the file, before asking the network for it.
val (dir, whereOrWhy) = apkStagingDir(9L * 1024 * 1024)
if (dir == null) {
lastFailure = "nowhere to stage the update — $whereOrWhy"
Log.e(TAG, "APK staging unavailable: $whereOrWhy")
return false
}
val apkFile = File(dir, "ScreenTinker-$version.apk")
@ -400,7 +415,7 @@ class UpdateChecker(private val context: Context) {
// #139: reuse a previously-downloaded, verified APK for this version instead of
// re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a
// manual install when silent install isn't possible.
if (apkFile.exists() && verifyApkSignature(apkFile)) {
if (apkFile.exists() && cachedApkIs(apkFile, version) && verifyApkSignature(apkFile)) {
Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
handler.post { installApk(apkFile) }
return true
@ -433,6 +448,17 @@ class UpdateChecker(private val context: Context) {
// Verify the downloaded APK is our package AND signed by the same key as
// the currently-installed app before installing. An attacker can't forge
// our signature, so this holds even over an untrusted transport.
// The server advertises a version and separately serves a file; the two can drift. A
// stale APK behind a current version number installs as a NO-OP, so the version never
// changes, the update is attempted again, and the panel loops until its attempts are
// spent — reporting a download failure, which it is not. Say what actually happened.
if (!cachedApkIs(apkFile, version)) {
val got = apkVersionName(apkFile) ?: "unreadable"
lastFailure = "server served $got but advertised $version — the update on the server is stale"
Log.e(TAG, "Version mismatch: advertised $version, downloaded $got")
apkFile.delete()
return false
}
if (!verifyApkSignature(apkFile)) {
// lastFailure was set precisely inside verifyApkSignature; keep it, and add the
// size so a truncated download is distinguishable from a genuine cert mismatch.
@ -464,11 +490,8 @@ class UpdateChecker(private val context: Context) {
try {
val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" }
val fileName = "pushed-" + (if (base.endsWith(".apk")) base else "$base.apk")
val dir = apkDir()
apkDirProblem(dir, 9L * 1024 * 1024)?.let {
Log.e(TAG, "installFromUrl: cannot stage — $it")
return@Thread
}
val (dir, whyNot) = apkStagingDir(9L * 1024 * 1024)
if (dir == null) { Log.e(TAG, "installFromUrl: nowhere to stage — $whyNot"); return@Thread }
val apkFile = File(dir, fileName)
if (apkFile.exists()) apkFile.delete()
val response = client.newCall(Request.Builder().url(url).build()).execute()
@ -573,6 +596,52 @@ class UpdateChecker(private val context: Context) {
// True only if the downloaded APK is this same package and shares a signing
// certificate with the installed app. Fail-closed on any error.
/* The versionName inside an APK file, or null if it cannot be read. */
private fun apkVersionName(apkFile: File): String? = try {
context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)?.versionName
} catch (e: Throwable) {
Log.w(TAG, "Could not read version from ${apkFile.name}: ${e.message}")
null
}
/*
* Is this file actually the version we mean to install?
*
* The cache is keyed by FILENAME, and the filename is built from the version the server
* advertised so a file called ScreenTinker-1.9.34.apk containing 1.9.33 passes a signature
* check (same key), gets reused on every attempt, and installs as a no-op forever. Fixing the
* server does not clear it; only deleting the file does. Checking the version inside makes that
* self-healing instead of needing a hand on the device.
*/
private fun cachedApkIs(apkFile: File, version: String): Boolean {
val got = apkVersionName(apkFile) ?: return false
if (got == version) return true
Log.w(TAG, "Cached ${apkFile.name} contains $got, expected $version — discarding")
return false
}
/*
* Delete every staged APK. The escape hatch for a panel holding a bad download: it forces the
* next check to fetch again rather than reuse. Safe at any time these are only ever caches,
* re-fetched on demand.
*/
fun clearUpdateCache(): Int {
var n = 0
for (dir in listOfNotNull(
File(context.filesDir, "Download"),
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
File(context.cacheDir, "Download"),
)) {
val files = try { dir.listFiles() } catch (_: Throwable) { null } ?: continue
for (f in files) {
if (!f.name.endsWith(".apk")) continue
if (f.delete()) n++
}
}
report("info", "Update cache cleared ($n file(s)) — the next check will download afresh")
return n
}
private fun verifyApkSignature(apkFile: File): Boolean {
return try {
val pm = context.packageManager

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
info:
title: ScreenTinker Public API
version: 1.9.34
version: 1.9.36
description: |
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.
Periodically restore the newest one into a throwaway instance and confirm it boots and serves
`/api/status`.
**`curl … | sudo bash` answers the installer's questions for you.** The pipe *is* stdin, and bash
has consumed it before any prompt runs — so every question gets an instant end-of-input and the
script takes the default. On the Pi installer that meant the mode menu appeared to skip itself and
Player-Only was unreachable through the documented command. Fixed there (prompts read the terminal
now), but the trap is general: any piped installer that asks you something is not really asking.
Download the script and run it, or pass the answers as flags.
**A Raspberry Pi 5 on Bookworm runs Wayland, and X11 tools fail silently there.** `xset`,
`unclutter` and `xrandr` return an error and do nothing — so screen blanking is never suppressed and
the cursor is never hidden, while every command in your setup notes appears to have worked. If you
have hand-rolled kiosk tweaks on a Pi, check which session is actually running (`echo
$XDG_SESSION_TYPE`) before trusting them. The bundled launcher detects this and uses `wlopm` plus
`--ozone-platform=wayland` on Wayland.
**Overlay FS protects the SD card and discards everything written to it.** Reasonable on a
**player-only** Pi, where the loss is a content cache that simply re-downloads after each boot. Not
usable for an **all-in-one** install as-is: the server writes continuously — the SQLite database,
WAL, uploads and thumbnails — and a read-only root throws all of it away at reboot, so the instance
silently reverts to its state at the moment you enabled overlay. If you want both, put `DATA_DIR` on
a writable partition that overlay does not cover, and confirm a screen you add survives a power cut
before relying on it.

View file

@ -683,6 +683,8 @@ export default {
'device.ctl.screen_on': 'Screen On',
'device.ctl.launch_player': 'Launch Player',
'device.ctl.force_update': 'Force Update',
'device.ctl.clear_update_cache': 'Clear Update Cache',
'device.ctl.clear_update_cache_tip': 'Delete any update file this display has already downloaded, so the next check fetches a fresh copy. Use if updates keep failing.',
'device.ctl.shutdown': 'Shutdown',
// Remote tab
'device.remote.start_prompt': 'Click "Start Remote" to begin',
@ -773,6 +775,7 @@ export default {
'device.toast.screen_on_sent': 'Screen on command sent',
'device.toast.launch_sent': 'Launch command sent',
'device.toast.update_triggered': 'Update check triggered',
'device.toast.update_cache_cleared': 'Update cache cleared — the next check will download afresh',
'device.toast.remote_started': 'Remote session started',
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',

View file

@ -501,6 +501,12 @@ async function loadDevice(deviceId, activeTab = null) {
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
${t('device.ctl.force_update')}
</button>
<button class="btn btn-secondary btn-sm" id="clearUpdateCacheBtn" title="${t('device.ctl.clear_update_cache_tip')}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
${t('device.ctl.clear_update_cache')}
</button>` : ''}
${can('system.reboot') ? `
<button class="btn btn-danger btn-sm" id="shutdownBtn">
@ -1593,6 +1599,12 @@ function setupActions(device) {
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
});
// Drops every staged APK on the panel so the next check downloads afresh. The escape hatch for a
// player holding a bad download — a cached file that cannot install but is reused every attempt.
document.getElementById('clearUpdateCacheBtn')?.addEventListener('click', () => {
sendWithFeedback('clear_update_cache', 'Clear update cache', 'device.toast.update_cache_cleared');
});
// #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
const uri = (document.getElementById('pipUri')?.value || '').trim();

View file

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -978,31 +978,19 @@ app.get('/api/version', (req, res) => {
app.use('/api/status', require('./routes/status'));
/*
* Opt-in install statistics COLLECTOR side. Inert unless TELEMETRY_COLLECTOR=1, so a normal
* self-hosted install never exposes this at all; only the deployment that gathers the numbers
* turns it on. 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 a 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.
* 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') {
app.post('/api/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 });
});
console.log('[telemetry] collector enabled at POST /api/telemetry/report');
/* `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

View file

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

View file

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

View file

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

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