Commit graph

991 commits

Author SHA1 Message Date
ScreenTinker d1842c3688 docs: changelog for 1.9.38
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 23:07:27 -05:00
ScreenTinker 68c7903cc8 test: stub navigator so the i18n test runs on Node 20 too
i18n.js sniffs navigator.language at module load. Node has had navigator as a global
only since 21, so the test passed locally on Node 24 and failed in CI on Node 20 with
"navigator is not defined" — a green local run that proved nothing about the version
CI actually uses. Stubbed explicitly, and verified on both 20 and 24 this time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 23:00:56 -05:00
ScreenTinker 9823aaf595 White-label: stop naming the upstream product to a reseller's customers (#292)
A partner reselling this platform reported that white-labelling changed the sidebar
title and the browser tab, and nothing else. Three fixes, in the order they matter to
them.

THE APK FILENAME, which they called the highest priority and which is a commercial
leak rather than a cosmetic one: every download landed on their customer's disk as
"ScreenTinker.apk", naming the upstream product — and where to buy it directly — to
the people they were selling to. /download/apk now resolves branding by DOMAIN, since
that route is unauthenticated and has no workspace to read, which is also exactly how
a reseller deploys: their own hostname, their own brand.

The name is sanitised through a whitelist, in lib/brand-filename.js so it can be
tested. That is security code, not cosmetics: brand_name is arbitrary operator text
landing in a Content-Disposition header, where a quote ends the filename parameter
early and a CR/LF ends the header line entirely. The tests are mostly hostile input.

ADMIN-CREATED USERS ARE VERIFIED. POST /api/admin/users left email_verified at the
schema default of 0, so every admin-provisioned user met a "Please confirm your email
address" banner they could not dismiss — and on an instance with no SMTP, could never
clear. Operators were fixing it by editing the database by hand. An address typed in
by an administrator is as verified as this system can make it. Note the test fixture
had drifted from the real schema and lacked the column entirely; adding it there is
what let the fix be tested at all.

THE HARDCODED STRINGS. Nine user-facing strings named the product — setup steps, the
empty-dashboard hint, onboarding, sign-in errors. They are translated strings, so the
substitution belongs in the translation layer: they now say {brandName}, and i18n.js
fills it in inside format(), so every t() call gets it without threading a variable
through several hundred call sites. Read at CALL time, not captured, so a workspace
switch shows the new brand rather than the one cached at module load. 43 strings across
7 locales; the default is the product's own name, so an un-branded install is unchanged.

Deliberately NOT changed, because substituting a brand there would be wrong rather than
incomplete:
  - the White Label brand_name input's placeholder, which shows the default when empty;
  - the install-statistics explanation, which describes what the upstream project can
    and cannot see, and is not about the reseller's brand;
  - the widget security warning, which describes the privileges of this software; that
    is copy worth changing deliberately rather than by regex.

Full suite 1779 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 22:55:56 -05:00
ScreenTinker b25bfaea57 Stop the player rendering black when the server hosts the display
A BrightSign hosting ScreenTinker shows a local page from file:///ssd:/node-server.html
that layers the player in an iframe — an iframe rather than a navigation, because
navigating replaces the document and kills the poller that notices the server dying.

helmet sets X-Frame-Options: SAMEORIGIN, and file:// is not the same origin as
http://127.0.0.1:8181, so the frame rendered BLACK. Every asset inside it returned 200
— the player page and all six of its scripts — and nothing appeared in any log. Only
the response headers said why, which is a miserable thing to debug on a device with no
console.

⚠️ AND IT IS NOT ONLY /player. Chrome evaluates SAMEORIGIN against the TOP-LEVEL
document rather than the immediate parent, so with a file:// page at the top, every
iframe the player itself uses — widget renders, kiosk views — is blocked by the same
rule one level deeper. Scoping this to /player would have cleared the black screen and
left every widget in the playlist black instead: the same bug, found later, on a
customer's wall. The test covers that case explicitly.

Scoped by CONTEXT, not by path: only when the process was started as a player host
(bs-server-boot.js sets ST_PLAYER_HOST; nothing else does) AND the request arrived on
loopback, i.e. from the box's own browser. An ordinary server keeps SAMEORIGIN, and so
does any request off the network — which is where clickjacking would have to come from,
since a remote page cannot reach another machine's 127.0.0.1. Where a CSP is set, only
its frame-ancestors directive is rewritten; the rest of the policy survives.

Verified on XT245 URD3C6000823: the player renders and shows its pairing code, while a
request to the same URL from the LAN still returns X-Frame-Options: SAMEORIGIN.
Full suite 1766 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 21:36:56 -05:00
ScreenTinker fbec5767c1 ci: run the node:sqlite job against the full tree, not a pruned one
The job as first written installed with --omit=optional, reasoning that a player has
no native module. It failed immediately with sixty "Cannot find module
'better-sqlite3'" errors — because the TESTS require it directly, as a harness to
inspect the database they are making assertions about. Pruning it tested nothing but
the test files.

Installing everything and forcing ST_SQLITE_DRIVER=node is also the stronger check:
the server under test runs on node:sqlite while the harness reads the same database
files with the native driver, so a divergence between the two surfaces as a failing
assertion rather than as one driver agreeing with itself.

This is the configuration that was actually verified locally — 1762 pass / 0 fail on
both drivers. The pruned variant was written afterwards and never run before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 21:07:38 -05:00
ScreenTinker 102daef237 Reconcile main with the rebased release history
An accidental push from a detached HEAD put the pre-rebase commits (e2884c6, 74d0ea3)
on main while the real work — including the v1.9.37 tag — sat on the rebased history.
Their content is identical to what 0ec8082 and 9fa69b6 already carry, so this merge
keeps this tree verbatim (-s ours) rather than force-pushing over commits that were
already published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:59:21 -05:00
ScreenTinker 663b392f70 chore(release): v1.9.37 2026-08-18 20:58:01 -05:00
ScreenTinker 1bb24e7604 Choose the SQLite driver at runtime, and ship the FFmpeg licence with the binaries
TWO CHANGES, together because they touch the same packager hunks.

1. THE DRIVER.

The BrightSign package used to be MANUFACTURED. scripts/build-server-zip.sh dropped
better-sqlite3 from package.json and then installed db/sqlite-compat.js into
node_modules under that name, so every require resolved to the façade. It worked —
and it shipped a database layer that no test had ever executed. That is the same
shape as the TELEMETRY_COLLECTOR TDZ crash that took production down while 1676
tests and four CI jobs were green: a build-time rewrite cannot be tested by the
build that performs it.

db/sqlite-driver.js now decides at runtime: the native driver when it loads, the
node:sqlite façade otherwise. One artifact, one code path, and — the point — both
branches reachable from a test. ST_SQLITE_DRIVER=node runs the entire suite the way
a player runs it, and a new CI job does exactly that on Node 24 with --omit=optional
so the fallback is reached the same way it is on hardware, not by an env var alone.

better-sqlite3 becomes an optionalDependency, so a host with no compiler installs
cleanly and falls back rather than failing. preflight-deps stops trying to rebuild a
native module on a host that has no toolchain and a working built-in driver — on a
player that was a five-minute node-gyp failure ending in a server that never started.
Asking for the native driver BY NAME (ST_SQLITE_DRIVER=better-sqlite3) still fails
loudly, because a production box that has lost its native module is broken and should
say so rather than quietly running something else.

⚠️ NODE 24 IN PRACTICE. node:sqlite is unflagged only from 23.4; on the 22.x line it
needs --experimental-sqlite and on 20.x it does not exist. So the code probes rather
than comparing versions, the player package pins engines >=24, and the built-in cases
skip on the Node 20 CI job rather than failing there.

Verified on Node 24, both drivers, full suite:
  better-sqlite3   1762 pass / 0 fail
  node:sqlite      1762 pass / 0 fail
and the built payload resolves node:sqlite with no better-sqlite3 present at all.

2. THE LICENCE.

The ffprobe/ffmpeg binaries added in the previous commit are LGPL 2.1 and statically
linked, so the licence text has to travel WITH them — a link on a website is not the
copy the licence asks to accompany the work. The packager now copies
COPYING.LGPLv2.1 and a build README into bin/, and refuses to build if the licence is
missing. legal/third-party.html gains an LGPL section with the written offer required
by section 6 for static linking, and the exact configure line.

It also drops Sharp, which that page still listed although #263 removed it, and names
what actually does the image work now (jimp, @jsquash/webp, @jsquash/avif).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:57:05 -05:00
ScreenTinker 0ec808298b Stop a self-referential node_modules symlink breaking the payload build
`server/node_modules` was tracked as a SYMLINK to its own absolute path:

    120000 blob ... server/node_modules -> /home/owner/Downloads/remote_display/server/node_modules

It came in with the #283 merge. Any attempt to resolve it is an ELOOP, so the
payload build died at the staging step with

    cp: cannot stat 'server/node_modules': Too many levels of symbolic links

and a fresh checkout gets a server/ whose dependencies cannot resolve at all.

.gitignore only had `node_modules/`, and a trailing slash matches DIRECTORIES —
which is exactly how a symlink of that name slipped past it. Both forms are listed
now, so the same mistake cannot be committed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:22:15 -05:00
ScreenTinker 9fa69b69f2 brightsign: ship ffprobe/ffmpeg in the payload so they survive updates
The staging code added in 4a4b9e3 reads bin/<tool>.gz from the install root, with
DATA_DIR as a fallback; until now only the fallback was populated, by hand. The
payload carries them, so an update refreshes the media tools along with the server.

PAYLOAD ONLY. The boot zip is read by the OS's own zip reader before anything else
runs, and a 73MB one failed outright with "ZipArchive error" — it is ~64KB and stays
that way.

Stored rather than compressed (the archive is -0 throughout) because they are gzipped
already: 3.2MB in the package against 6.7MB of tmpfs once unpacked.

They live in brightsign/media-tools/ and are excluded from the wholesale brightsign/
staging, or the package would carry both copies. A missing file is a hard error, not
a warning: both are tracked, so absence means someone removed them, and the symptom
would otherwise be a player that silently stops making video thumbnails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:21:49 -05:00
ScreenTinker 74d0ea3752 brightsign: ship ffprobe/ffmpeg in the payload so they survive updates
The staging code added in 4a4b9e3 reads bin/<tool>.gz from the install root, with
DATA_DIR as a fallback; until now only the fallback was populated, by hand. The
payload carries them, so an update refreshes the media tools along with the server.

PAYLOAD ONLY. The boot zip is read by the OS's own zip reader before anything else
runs, and a 73MB one failed outright with "ZipArchive error" — it is ~64KB and stays
that way.

Stored rather than compressed (the archive is -0 throughout) because they are gzipped
already: 3.2MB in the package against 6.7MB of tmpfs once unpacked.

They live in brightsign/media-tools/ and are excluded from the wholesale brightsign/
staging, or the package would carry both copies. A missing file is a hard error, not
a warning: both are tracked, so absence means someone removed them, and the symptom
would otherwise be a player that silently stops making video thumbnails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:21:36 -05:00
ScreenTinker e2884c624d Stop a self-referential node_modules symlink breaking the payload build
`server/node_modules` was tracked as a SYMLINK to its own absolute path:

    120000 blob ... server/node_modules -> /home/owner/Downloads/remote_display/server/node_modules

It came in with the #283 merge. Any attempt to resolve it is an ELOOP, so the
payload build died at the staging step with

    cp: cannot stat 'server/node_modules': Too many levels of symbolic links

and a fresh checkout gets a server/ whose dependencies cannot resolve at all.

.gitignore only had `node_modules/`, and a trailing slash matches DIRECTORIES —
which is exactly how a symlink of that name slipped past it. Both forms are listed
now, so the same mistake cannot be committed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:21:36 -05:00
ScreenTinker 4a4b9e343a brightsign: stage our own ffprobe/ffmpeg into /tmp so media tools work
The server logged "[MEDIA] ffmpeg, ffprobe not found on PATH — video thumbnails
and durations are DISABLED" on every boot of a player-hosted server. It now says
"found — video thumbnails enabled", because the binaries are shipped gzipped,
unpacked into /tmp at startup and put on PATH before server.js is required (its
probe looks them up by name with execFile, so PATH is the whole mechanism).

/tmp is not laziness, it is the only option, and the alternatives were measured on
an XT245 rather than assumed:

  /storage/ssd    bsexfat  rw,nosuid,nodev,noexec,...     <- exec => EACCES
  /storage/flash  ext4     rw,nosuid,nodev,noexec,...     <- same
  /storage/tmp    tmpfs    rw,nosuid,nodev,noexec,...     <- same
  /tmp            tmpfs    rw,relatime                    <- the one that permits exec

We run as uid=994(nodejs), so `mount -o remount,exec` answers "permission denied
(are you root?)" on both volumes, and there is no setuid path to it: BrightSign
points mount at busybox.nosuid, and their busybox.suid carries login/passwd/vlock
and no mount applet. A symlink does not help either — noexec is a property of the
filesystem holding the inode, not of the path used to reach it, so a link in /tmp
pointing at flash still fails EACCES. Copying is what moves the inode onto a
filesystem that permits execution.

The binaries are ours and deliberately link nothing of BrightSign's. The OS does
ship the whole ffmpeg 5.1 stack (libavformat/libavcodec/... backing GStreamer) and
a stock Debian ffprobe against those libs starts, prints its banner, and then
SIGSEGVs the moment it opens a file — their Yocto build is patched for hardware
decode. So these are cross-built FFmpeg 7.1.1, --disable-gpl (LGPL 2.1+), fully
static, --enable-small. ffprobe carries no decoders at all (durations and geometry
come from the container) which is why it is 1.8MB against ffmpeg's 4.9MB; ~6.7MB of
tmpfs on a box with 2.8GB free, ~3.2MB gzipped at rest.

Verified on XT245 URD3C6000823: unpack 19ms, ffprobe -version 9ms,
format=duration 3.533333 against a 3.533333s clip, stream 320x240 h264.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:12:26 -05:00
ScreenTinker a87bd7d875 brightsign: say what the 8182 status listener is for on screen
"status listener on 127.0.0.1:8182" told an operator a port was open and nothing
about why, next to a server that advertises 8181 - it reads like a stray listener.
It now says it feeds the diagnostics screen while the app is downloading, starting
or down, and that nothing off-device can reach it.

The port is interpolated through currentPort() rather than captured: server.env is
read after this module is evaluated, so a captured value would print the 3001
default instead of the port actually serving. Verified on the device - the line
renders ":8181".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 17:42:34 -05:00
ScreenTinker 350cd31414 brightsign: read the server flag as an integer, not a Boolean
`if serverEnabled then` demands a genuine Boolean in BrightScript. ServerEnabled()
was declared As Boolean but branched on type() over ParseJSON output - "Boolean",
"Integer", "roInt", "String" - and none of those reliably produced one for a JSON 1.
The device took the whole file down with it:

    Script runtime error: Type Mismatch. (runtime error &h18) in SSD:/autorun.brs(63)
    Load or runtime error in autorun. Forcing recovery.

...and then looped through recovery, re-fetching autorun.zip - which contained the
same broken file, so recovery could not recover.

ServerEnabledFlag() returns 0 or 1 and the call sites compare explicitly. It finds
the setting by substring rather than by parsing, which is cruder and can be fooled
by a "server" key inside another string, but cannot mismatch a type. There is no
BrightScript interpreter on the machine this is written on, so every type guess
costs a boot cycle plus a recovery loop; a config file we write ourselves does not
justify that risk. Anything short of a clear yes stays a player.

Verified on XT245 URD3C6000823: clean boot, no runtime error, server up on :8181
45s after reboot, diagnostics screen showing the setup address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 17:37:38 -05:00
screentinker 0b063cd415
Make the on-device server opt-in, and stop the status port answering the LAN (#291)
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 / Licence gate + SBOM (production deps) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
A fleet gets one package, and exactly one box per site should host the server.
Defaulting to on would mean every player that ever received this package
started listening on 8181, and the mistake would stay invisible until two of
them fought over the same displays.

st-config.json on the storage root, {"server": 1}, switches it on. Absent,
unreadable, unparseable, or anything other than an affirmative value leaves it
off - there is no reading of a broken config file that should end with a
device deciding to host a server. It sits at the root rather than in data/
because that is where an operator drops it over the DWS, and autozip never
writes it, so a re-provision cannot silently flip a site either way. The
package ships st-config.example.json, never st-config.json, for the same
reason.

With the server off, NOTHING listens: roNodeJs is never created, so there is
no 8181 and no 8182. The page is told through its URL rather than discovering
it, because "nothing is answering" would otherwise render as a fault and send
someone looking for a server that was never meant to exist. It now has a
fourth state that says so and offers the one line of JSON that changes it.

Separately: the status listener was bound to every interface, so anything on
the customer's LAN could read the install log, disk usage, the device's own
address and a tail of the server's console - that last one carries whatever
the server printed most recently. Its only consumer is a page on the same
device. Now 127.0.0.1 only, confirmed against /proc/net/tcp rather than by
probing, after a first attempt at verifying it fell back to loopback and
reported that as the LAN result.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:32:37 -05:00
screentinker 326da8a730
Show the player on the player, and the diagnostics when something is wrong (#290)
The box is now both server and player, so its screen has to be one or the
other at any moment. Three states, and the transitions are the point:

  installing / down / failed  diagnostics, so the fault is visible
  up, but no account yet      diagnostics plus the address to create one
  up, and an account exists   the player, full screen

A fresh install has nothing to play and nobody to play it for, so it stays on
the configuration screen until someone has signed up. Hiding that address
would leave the device unsetuppable: it has no keyboard.

⚠️ THE PLAYER IS AN IFRAME LAYER, NOT A NAVIGATION. Setting location.href
would replace the document and take the poller with it - and that poller is
the only thing able to notice the server failing later. As a layer, the
diagnostics are one style change away from being back on screen, which is
exactly what should happen when a server that has been playing for weeks
throws at 3am. A test asserts location.href is never assigned, so this cannot
be quietly simplified back.

Whether an account exists is asked by the wrapper, not the page:
/api/auth/config is public, but the page is loaded from file:// - origin
"null" - and the server sets no CORS headers on its own API, while this
process is already talking to it. The answer is three-valued. null means the
probe has not replied yet and is deliberately NOT treated as false: guessing
would flip a fresh box to an empty player and take the sign-up address off
the screen while someone was reading it.

Verified against a real server rather than by inspection - install, sign up,
watch it flip:

  BEFORE signup : needsSetup=null   -> diagnostics
  POST /api/auth/register -> HTTP 201
  AFTER  signup : needsSetup=false  -> player


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:23:23 -05:00
screentinker 7c6cfeecfd
Stop database snapshots losing their permissions (#289)
copyFileBytes replaced fs.copyFileSync because copyFileSync does not merely
copy bytes - it fchmods the destination to match the source, and exFAT has no
permission bits, so the pre-migration snapshot failed with EPERM on a player.

The replacement dropped the chmod entirely, which fixed that and introduced a
worse problem everywhere else: the copy landed at the default 0666 & ~umask.
Measured on ext4, a 0600 database file copied to a 0664 snapshot - the whole
database readable by group and other, on every install, not just on a player.

Removing a permission operation to fix a permission error is not a fix.

The mode is now applied as a separate, failure-tolerant step after the bytes
are written. That is the actual difference from copyFileSync: there the chmod
is inseparable from the copy, so a filesystem without modes fails the whole
operation; here the data is already safe and a refusal simply means there were
never permissions to carry across.

Two tests: the source mode survives the copy (fails on the current main), and
a filesystem that refuses fchmod still gets its bytes.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:24:39 -05:00
screentinker 9a1a82a100
Run the ScreenTinker server on the player it serves (#288)
* Make a BrightSign say what it is running, and what it is plugged into

A panel on a wall could not answer three questions an operator asks first:
which version am I, which page am I running, and which screen is that. All
three had answers already travelling over the socket; nothing was reading them.

VERSION. device_info.app_version was the literal '1.1.0-web' for every web
player, BrightSign included — the same string as PLAYER_VERSION, which already
travels separately as client_version. So the column carried no information at
all: a panel provisioned this morning and one running a year-old host reported
identically. app_version is now the ON-DEVICE host package, the artifact OTA
replaces and the only one here that can be stale, and PLAYER_VERSION is stamped
at serve time from VERSION rather than being a constant nobody bumped for the
whole 1.x line. No '-web' suffix: client_version is only compared for equality
today, but X.Y.Z-web is a semver PRERELEASE that sorts BELOW X.Y.Z, and this
project has been bitten by exactly that before.

The host version arrives asynchronously and can land after the page registers,
so register sends what it has and the heartbeat corrects the record — which also
catches the version changing under a live page, which is what a self-update is.

THE CARD SHOWED FOR NOBODY. The Info tab's version card sat inside the block
gated on android_version && !startsWith('Web/'). A BrightSign registers as
"Web/<ua>", so the panel that most needed a version never displayed one.

THE PAD THAT COULD NOT BE CLICKED. System View was gated on tier === 2. tier is
an Android device-owner concept, NOT NULL DEFAULT 0, written only by the APK —
so a BrightSign or Tizen panel sat at 0 forever and rendered HOME, BACK, POWER,
the D-pad and OK permanently pointer-events:none, for keys those players
genuinely handle. Greying an Android gate over a working control is the "button
that cannot work" the capability system exists to prevent, inverted. Only
Recents (KEYCODE_APP_SWITCH) and Settings are truly Android-only; those are now
the only things hidden.

THE PACKAGE POINTED AT THE WRONG SERVER. autorun.zip carried the committed
default, so a player self-updating from alpha or a self-hosted box was handed a
config pointing at screentinker.com — which surfaces as a pairing bug, miles
from the packaging code that caused it. It is now stamped with the URL it was
fetched from. The bytes therefore vary per origin, so the cache is keyed by
origin and BOTH routes derive it identically: the manifest checksum and the
served bytes must come from one buffer or every player downloads, fails
verification and retries forever.

EDID. getEdidIdentity() answers seven questions and cannot answer any others —
manufacturer, EDID version, physical size, gamma and the mode lists exist only
in the raw block, which getEdid() returns as 2048 bytes. The player ships those
on the register (identity, not a reading: it changes when someone swaps the
screen) and the SERVER parses them. That split is the point: a new field becomes
a server deploy instead of a bridge update behind a 4h CDN plus an OTA for the
host. Verified against real hardware — an XT245 with a CX101 decodes to RTK /
0x1010 / serial 1 / 2020w26 / 22x13cm, preferred 1920x1200@62, matching the
player's own DWS field for field. The odd-looking 62 is right: 168.5MHz over
2200 x 1245 is 61.5Hz, and rounding it to a nicer 60 would contradict the panel.

Also corrects two comments that had outgrown their reasoning: the BrightSign
capability baseline still explained its exclusions with "a canvas cannot read
the video plane", which native capture made obsolete, and player-parity.md
claimed the bridge is "always current" when a zone-wide Cloudflare Browser Cache
TTL had been rewriting its no-cache to max-age=14400 for months.

Every new guard is mutation-tested — the fix was reverted in the source and each
test confirmed to fail. 1676 -> 1714 tests, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

* Run the ScreenTinker server on the player it serves

A BrightSign XT245 now downloads, installs and runs the server itself, with
the display showing what it is doing until it is up.

WHY IT NEEDED A NEW SHAPE

BrightSignOS cannot open a large autorun.zip. The 73MB build failed at boot
with "ZipArchive error at line 91", and the OS renamed it autorun.zip_invalid
- which is how a device that had already unpacked once came back up with no
autorun at all. The identical package cut to 32KB and five files boots fine;
paths (182 chars) and depth (8) are unremarkable, so the limit is in the
boot-time reader, not the archive. BrightSign's own notes acknowledge package
size as a problem and point at webpack; that route needs the dynamic requires
in scripts/ removed first, so instead autorun.zip carries only what starts the
process and the payload arrives over HTTP into a Node that has no such limit.
The payload can also be updated without re-provisioning the device.

WHY roNodeJs AND NOT THE WIDGET

The first version ran the server inside an roHtmlWidget with nodejs_enabled.
That is a Node context inside an Electron renderer, and it is not Node. Four
separate boot failures came out of it, each invisible to a local test because
a local test runs on real Node:

  - shebangs are not stripped, so any `#!/usr/bin/env node` file dies with
    "Failed to construct 'ContextifyScript': Invalid or unexpected token".
    Note it names no token - "#" is not one. An ESM file compiled as CJS says
    "Unexpected token 'export'" instead, which is how the two are told apart.
  - require() of an ESM-only package is unsupported, which plain Node 24
    handles. uuid 14 is ESM-only and 21 files import it.
  - setInterval is the DOM's and returns a NUMBER, so setInterval(...).unref()
    throws. Two call sites were unguarded; sixteen more were written
    defensively and had been silently not unreffing.
  - worker_threads cannot create a thread at all.

BrightSign's dev-cookbook is explicit: roNodeJs "for long running processes
like ... running a web server", roHtmlWidget "for browser-based apps". Their
cra-template examples do exactly this - server in roNodeJs, widget pointed at
localhost. It also fixes the lifecycle problem that was the original argument
against a server on this hardware: in a widget the server dies with the page,
taking an open SQLite WAL with it.

The shims for the first three are kept in the packager for now rather than
removed in the same change that moves the container, so that if something
breaks it is the move and not four simultaneous removals.

CHANGES THAT ARE NOT BRIGHTSIGN-SPECIFIC

  db/database.js, routes/status.js  fs.copyFileSync does not merely copy
    bytes: it fchmods the destination to match the source. exFAT has no
    permission bits, so the pre-migration snapshot failed with EPERM and the
    failure path called process.exit(1) - which inside a widget also killed
    the page, leaving a black screen and no diagnostic. The guard was right;
    the copy was wrong. lib/fsutil.js copies without touching mode.

  db/wal-checkpointer.js  the module already degraded correctly when its
    worker died or could not be respawned, but the FIRST spawn was not
    wrapped, so a host that cannot make threads lost the whole server rather
    than falling back to inline autocheckpoint.

  db/sqlite-compat.js  a better-sqlite3 facade over node:sqlite. With it the
    bundle contains no native code at all, which is what lets an x86_64
    laptop build a package for an aarch64 player. 1719/1719 tests pass on
    Node 24 through this shim.

The packager refuses to build if a source file is untracked (git ls-files
decides what ships, and lib/fsutil.js reached a player without shipping
alongside the code that required it), if any .node binary is present, if a
shebang survives, or if a database, upload, cert or .env is staged - the first
build of this package swept up a real 33MB database and 105MB of uploads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
2026-08-18 15:16:09 -05:00
screentinker 1e99582193
Keep every field on screen during first-run setup (#287)
On a fresh install with no users, typing an email address made the password
field disappear.

Identifier-first login asks the server which identity provider an address uses
before offering a credential, so someone whose organization requires its own
IdP is never shown a password box that will be refused. First-run setup opted
out of that by setting `identified = true` up front - there is nobody to
identify, the operator is creating the first account - and then the "editing
the address returns to the identifier step" listener fired on the very first
keystroke, set it back to false, and re-rendered. Password gone, mid-typing.
The same re-render also replaced "Create admin account" on the button with
"Sign in" and then "Next".

Initialising a flag to the right value is not the same as the flow being
inert: any later event could undo it, and one did. The decision now lives in
frontend/js/lib/login-form-state.js as a pure function that ignores
`identified` and `ssoOnlyDomain` entirely when there are no users, so no event
can take a field away. The input listener also returns early during setup,
which additionally stops it spending an org-lookup rate-limit budget that has
nothing to answer.

Two assertions in login-identifier-first.test.js pinned those expressions to
their old address inside login.js. Their intent - "the two drivers must be
combined in one place so they cannot disagree" - is better served by the
extracted module, so they now follow the logic there rather than being
loosened.

Verified: the extracted pre-fix logic returns showPassword=false and
buttonKey=auth.next for setup-plus-one-keystroke, which is the reported
symptom exactly; the new truth table covers it. i18n/login/auth/session/sso
suites go from 108 to 116 passing, none failing.

Not addressed here, and worth its own change: loadAuthConfig() has no error
handling and caches its first result, so a /api/auth/config that fails leaves
needsSetup undefined and the form falls back to the restrictive layout - the
same visible symptom from a different cause.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:50:54 -05:00
screentinker 0511e9b5bb
Let a locale ship without every string translated (#286)
Follow-up to #285. Three corrections that rode in with the Japanese locale.

1. The ja key-parity check failed the build whenever en.js had a key ja.js
   lacked. i18n.js lookup() is already
   `registry[lang]?.[key] ?? fallback[key] ?? key`, so an untranslated string
   renders in English and nothing is broken by a gap - the only effect was
   that adding any English string blocked CI until a Japanese translation
   existed. It also singled out one locale; es/fr/de/pt/hi/it were never held
   to it, and hi.js is a deliberate skeleton whose own header explains that
   every key falls back to English on purpose.

   Replaced with two checks over EVERY locale: a locale may not define a key
   that English does not (dead weight after a rename, and fixable by whoever
   touched the file, whatever language they speak), and coverage is printed
   rather than gated. Help tips still have to exist everywhere - that test is
   unchanged and still fails.

   Current coverage: ja 100%, es 65.5%, fr/de/pt 63.7%, it 59.5%, hi 0%.

2. Applying the strict half to all locales immediately found
   add_display.smart_tv_note living in fr, pt, it and de but not in en.js and
   referenced by no view - a string dropped from English that left four
   translations behind. Removed.

3. The new timezone test restored process.env.TZ by assigning the saved value
   back. When TZ was not set to begin with - which is the case in CI - that
   assigns undefined, which writes the STRING "undefined"; Node cannot parse
   it and silently falls back to UTC for the rest of the process. Every test
   after it in that file is date arithmetic. It now deletes the key when it
   was previously unset.

4. package-lock.json removed from .gitignore. server/package-lock.json is
   tracked, so the rule was inert, but it would silently prevent a future
   lockfile and works against the SBOM and reproducible-install setup added
   in #282.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:37:56 -05:00
screentinker 70af0d0194
Merge Japanese localisation (#283) (#285)
* Add complete Japanese localization

* Checked and fixed translations

* Preserve selected calendar dates across timezones

---------

Co-authored-by: giyokun <gproux@gmail.com>
Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: giyokun <giyokun@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:23:28 -05:00
screentinker 65a24682f0
Stop schedules landing on the wrong date (#284)
Three separate defects all showed up as "I saved a schedule and it moved".

1. expandSchedule emitted two different wire formats. A one-off returned
   start_time untouched - a naive wall-clock string, which the browser parses
   in its own zone - while a recurring instance returned cursor.toISOString(),
   an absolute instant derived by reading that same string in the SERVER's
   zone. The browser converted it back and the two conversions do not cancel:
   an operator in Tokyo saving Wednesday 20:00 got Thursday 10:00 from a
   US-Central server. Only recurring schedules were affected, which is why it
   looked intermittent.

   The calendar was also the odd one out. services/scheduler.js compares
   start_time as a string and never builds a Date from it, so expandSchedule
   was the only place in the codebase treating a wall-clock time as an
   instant - the drawing disagreed with playback as well as with the browser.

2. Saving an edit moved the schedule to today. The save handler rebuilds
   start_time from `pendingCreateDate || new Date()`, and editSchedule()
   restored only HH:MM - it never recorded the date being edited. Changing a
   colour on a block dated 5 Aug rewrote it to this week. No timezone
   mismatch required; this one hit everybody and silently altered stored data.

3. A cancelled drag-create leaked its date into the next schedule.
   pendingCreateDate was cleared only on a successful save, and the modal's
   two dismissers are inline onclick="...display='none'" attributes that
   cannot reach that scope. The date is now assigned by every path that OPENS
   the modal, so no dismissal path can leave a stale value behind.

Tests assert the property rather than a literal: that the wire value is
wall-clock and that the day survives a server/browser zone mismatch in BOTH
directions. A literal-string assertion would pass just as happily with the
bug present on a differently-configured CI box. Both new files fail on the
parent commit and pass here; the schedule, calendar and timezone suites are
green at 85 tests.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:14:23 -05:00
screentinker 3f9459139f
Gate licences in CI and publish an SBOM (#282)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Licence gate + SBOM (production deps) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
The licence audit that found org.json in the APK was run by hand. Nothing stopped the next
transitive dependency arriving the same way, and "we track licences" was a claim rather than
something anyone could check.

TWO GATES, BOTH FAIL CLOSED.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* OTA: prove the destination is writable before downloading

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

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

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

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

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

The pushed-APK path gets the same preflight; it shared every one of these failure
modes and reported none of them.
2026-08-13 19:57:27 -05:00
ScreenTinker 0b10776701 chore(release): v1.9.34-alpha10 2026-08-13 19:00:53 -05:00