mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-19 08:33:56 -06:00
* 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>
192 lines
9 KiB
JavaScript
192 lines
9 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* Builds and serves the BrightSign player package (autorun.zip) for self-update.
|
|
*
|
|
* THE INVARIANT THIS FILE EXISTS TO HOLD: the checksum in the manifest and the bytes on the
|
|
* download route come from the SAME in-memory buffer, built once. Advertising a version whose
|
|
* checksum does not match the bytes actually served is the classic OTA-loop condition — the player
|
|
* downloads, fails verification, retries, forever — and it is the easiest mistake to make when the
|
|
* manifest is computed from one source and the file from another (a file on disk that a deploy
|
|
* replaced, say). Here it is impossible by construction: there is one buffer and both routes read
|
|
* it.
|
|
*
|
|
* The zip is built deterministically from brightsign/, not read from a prebuilt artifact, because a
|
|
* prebuilt autorun.zip is a CI output that is not present in a git-checkout deployment. Building it
|
|
* means the manifest is always available and always describes files that actually exist.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const archiver = require('archiver');
|
|
|
|
// The payload, mirroring scripts/build-autorun-zip.sh. autozip.brs must be present or nothing
|
|
// unpacks the archive on the player; autorun.brs must be INSIDE it and never beside it on the
|
|
// storage root, or the player refuses to process the zip at all.
|
|
const PACKAGE_FILES = ['autozip.brs', 'autorun.brs', 'offline.html', 'screentinker.json'];
|
|
|
|
// sha256 rather than sha1 because that is the algorithm BrightScript's roMessageDigest is
|
|
// documented against — the player has to be able to verify what we advertise, and an algorithm it
|
|
// cannot compute is an unverifiable package, which this whole design exists to refuse.
|
|
// Keyed by the server URL stamped into the package, because that URL changes the BYTES and
|
|
// therefore the checksum. The invariant at the top of this file is per-key: a player asking the
|
|
// manifest route and the download route hits the same key both times (both derive the URL the same
|
|
// way from the same request), so it still sees one buffer and one checksum. Bounded, because the
|
|
// key is derived from a request header — an unbounded map keyed on attacker-controlled input,
|
|
// holding a ~73KB buffer per entry, is a memory-growth primitive.
|
|
const MAX_CACHED_PACKAGES = 8;
|
|
const cache = new Map(); // serverUrl|'' -> { version, sha256, size, buffer }
|
|
|
|
/*
|
|
* The URL to stamp, from the request that asked for the package.
|
|
*
|
|
* A zip fetched from alpha should point at alpha; one fetched from prod, at prod. Getting this
|
|
* wrong is silent and expensive: the player provisions, registers against the WRONG instance, and
|
|
* looks like a pairing bug rather than a packaging one.
|
|
*
|
|
* APP_URL wins where it is set, matching how every other self-referential URL in this codebase is
|
|
* built (routes/org-sso.js, routes/auth.js, server.js). The Host fallback is what makes a
|
|
* self-hosted deployment work with no configuration at all — the point of the feature — and it is
|
|
* sanitised and length-capped before it can reach a config file on a player, the same treatment
|
|
* routes/auth.js gives it.
|
|
*/
|
|
function packageServerUrl(req) {
|
|
const configured = String(process.env.APP_URL || '').trim().replace(/\/+$/, '');
|
|
if (configured) return configured;
|
|
if (!req) return null;
|
|
const host = String(req.get ? req.get('host') || '' : '').trim();
|
|
// VALIDATE, do not scrub. Stripping disallowed characters turns `a.example"; rm -rf /` into
|
|
// `a.examplermrf` — harmless, but it ships a plausible-looking host that resolves nowhere and
|
|
// sends the next person hunting a DNS problem. Anything that is not a clean hostname[:port]
|
|
// yields null, and null means "ship the committed default", which is always a working answer.
|
|
if (host.length > 100 || !/^[A-Za-z0-9.-]+(:\d{1,5})?$/.test(host)) return null;
|
|
const proto = req.protocol === 'http' ? 'http' : 'https';
|
|
return `${proto}://${host}`;
|
|
}
|
|
|
|
/*
|
|
* Rewrite server_url in screentinker.json.
|
|
*
|
|
* Parsed and re-serialised rather than string-replaced so a malformed URL cannot inject structure
|
|
* into the config the player reads. Returns the ORIGINAL text on any failure: shipping the
|
|
* committed default is a recoverable mistake, shipping a corrupt config is not — autorun.brs reads
|
|
* this file at boot and a parse failure there is a player that never starts.
|
|
*/
|
|
function stampServerUrl(source, serverUrl) {
|
|
try {
|
|
const cfg = JSON.parse(source);
|
|
cfg.server_url = serverUrl;
|
|
return JSON.stringify(cfg, null, 2) + '\n';
|
|
} catch (e) {
|
|
return source;
|
|
}
|
|
}
|
|
|
|
function brightsignDir() {
|
|
return path.join(__dirname, '..', '..', 'brightsign');
|
|
}
|
|
|
|
function readVersion() {
|
|
try {
|
|
return fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Rewrite the stamped version line in autorun.brs.
|
|
*
|
|
* Anchored on the ST_PACKAGE_VERSION marker rather than on the literal, so a hand-edited default
|
|
* cannot cause a silent miss. If the marker is ever removed the stamp is skipped and the package
|
|
* ships reporting "0.0.0-dev", which reads as permanently out of date — noisy, but noisy in the
|
|
* direction of "someone look at this" rather than a silent update loop.
|
|
*/
|
|
function stampVersion(source, version) {
|
|
return source.replace(
|
|
/return "[^"]*"(\s*'\s*ST_PACKAGE_VERSION)/,
|
|
`return "${version}"$1`
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Build the archive in memory. Entries are added in a fixed order with a fixed timestamp so the
|
|
* bytes are reproducible: a checksum that changed on every server restart would make every player
|
|
* re-download the same package after every deploy.
|
|
*
|
|
* Reproducibility is per serverUrl — same URL in, same bytes out. A player only ever sees one URL
|
|
* (its own), so from its point of view nothing changed.
|
|
*/
|
|
function buildZip(serverUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
const dir = brightsignDir();
|
|
const chunks = [];
|
|
// STORED, no compression — not a size/speed choice. A player could not open our first
|
|
// deflated archive: BrightSign's automated deployment copied it across and then reported it
|
|
// invalid. The bootstrap extracts autozip.brs before any script runs, and roBrightPackage
|
|
// supports a specific set of methods, of which "no compression" is the universally safe one.
|
|
// A compressed package deploys perfectly and then fails to open, which reads as a broken
|
|
// deployment rather than a broken zip.
|
|
const archive = archiver('zip', { store: true });
|
|
|
|
archive.on('data', (c) => chunks.push(c));
|
|
archive.on('error', reject);
|
|
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
|
|
const version = readVersion();
|
|
for (const name of PACKAGE_FILES) {
|
|
const p = path.join(dir, name);
|
|
if (!fs.existsSync(p)) return reject(new Error(`package file missing: ${name}`));
|
|
let body = fs.readFileSync(p);
|
|
// Stamp the version into the host so the script REPORTS the version it actually is. Ship it
|
|
// unstamped and the player applies the update, still reports the old version, and is offered
|
|
// the same package forever — the OTA loop, arriving by the back door.
|
|
if (name === 'autorun.brs') body = Buffer.from(stampVersion(body.toString('utf8'), version), 'utf8');
|
|
// Point the package at the server it was fetched FROM, so a zip pulled from alpha provisions
|
|
// against alpha. scripts/build-autorun-zip.sh --server does the same thing for the offline
|
|
// path (an SD card written with no server in the loop); this covers the online one.
|
|
if (name === 'screentinker.json' && serverUrl) {
|
|
body = Buffer.from(stampServerUrl(body.toString('utf8'), serverUrl), 'utf8');
|
|
}
|
|
// date fixed for reproducibility; the player never reads it.
|
|
archive.append(body, { name, date: new Date(0) });
|
|
}
|
|
archive.finalize();
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Get the package, building once and caching. Returns null when the package cannot be built (a
|
|
* deployment without the brightsign/ directory, for instance) — callers must treat that as "no
|
|
* manifest", which the update decision reads as "keep running", never as "wipe yourself".
|
|
*/
|
|
async function getPackage(serverUrl) {
|
|
const key = serverUrl || '';
|
|
const hit = cache.get(key);
|
|
if (hit) return hit;
|
|
const version = readVersion();
|
|
if (!version) return null;
|
|
try {
|
|
const buffer = await buildZip(serverUrl || null);
|
|
const built = {
|
|
version,
|
|
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
|
size: buffer.length,
|
|
buffer
|
|
};
|
|
// Evict oldest-first. Map preserves insertion order, and the realistic working set is one or
|
|
// two hostnames — the bound exists for the pathological case, not the normal one.
|
|
if (cache.size >= MAX_CACHED_PACKAGES) cache.delete(cache.keys().next().value);
|
|
cache.set(key, built);
|
|
return built;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/* Test seam: drop the cache so a changed file is picked up without a restart. */
|
|
function _reset() { cache.clear(); }
|
|
|
|
module.exports = { getPackage, packageServerUrl, _reset, PACKAGE_FILES };
|