screentinker/brightsign/server/bs-payload-install.js
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

299 lines
13 KiB
JavaScript

'use strict';
/*
* Fetch and unpack the server payload, on the player, in pure JavaScript.
*
* WHY THIS EXISTS. BrightSignOS cannot open a large autorun.zip. The 73MB build of this server
* failed at boot with
*
* Failed to use zipped 'SSD:/autorun.zip': ZipArchive error at line 91
* Load or runtime error in autorun. Forcing recovery.
*
* and the OS renamed the archive to autorun.zip_invalid — which is how a device that had once
* unpacked successfully came up with no autorun at all. The identical package cut down to 32KB and
* five files boots fine, so the limit is in the OS's boot-time zip reader, not in the archive:
* paths (max 182 chars) and depth (8) are unremarkable, and provisioning unpacks the big one
* happily.
*
* So autorun.zip carries only what is needed to start, and the ~71MB of server + node_modules comes
* down over HTTP into a Node process that has no such limit. A side benefit worth having: the
* payload can be updated without re-provisioning the device.
*
* NO DEPENDENCIES, deliberately. This code runs *before* node_modules exists, so it cannot use
* anything from it. That is less painful than it sounds — the payload is STORED, so the common case
* is copying byte ranges, and DEFLATE is handled by the built-in zlib for anything that is not.
*/
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const http = require('http');
const https = require('https');
const EOCD_SIG = 0x06054b50;
const CD_SIG = 0x02014b50;
const LOCAL_SIG = 0x04034b50;
const ZIP64_EOCD_LOCATOR_SIG = 0x07064b50;
/* ------------------------------------------------------------------------------------------- */
/* Download */
/* ------------------------------------------------------------------------------------------- */
/*
* Straight to a file, never into memory. The payload is ~71MB on a player with other things to do;
* buffering it whole would work today and stop working the first time the bundle grows.
*
* Downloads to a .part and renames on completion, so an interrupted transfer — a reboot mid-fetch is
* entirely normal on a device someone can unplug — can never be mistaken for a finished one.
*/
function download(url, dest, onProgress, redirectsLeft = 5) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https:') ? https : http;
const req = mod.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (redirectsLeft <= 0) return reject(new Error('too many redirects'));
const next = new URL(res.headers.location, url).toString();
return resolve(download(next, dest, onProgress, redirectsLeft - 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error('HTTP ' + res.statusCode + ' fetching ' + url));
}
const total = parseInt(res.headers['content-length'] || '0', 10) || null;
let got = 0;
const part = dest + '.part';
let out;
try { out = fs.createWriteStream(part); } catch (e) { return reject(e); }
res.on('data', (chunk) => {
got += chunk.length;
if (onProgress) onProgress(got, total);
});
res.pipe(out);
out.on('error', reject);
out.on('finish', () => {
try {
// A truncated body that still ended cleanly is a real failure mode on flaky links, and it
// produces a zip whose central directory is simply missing — an error far from the cause.
if (total !== null && got !== total) {
fs.unlinkSync(part);
return reject(new Error('short download: ' + got + ' of ' + total + ' bytes'));
}
fs.renameSync(part, dest);
resolve({ bytes: got });
} catch (e) { reject(e); }
});
});
req.on('timeout', () => req.destroy(new Error('timed out fetching ' + url)));
req.on('error', reject);
});
}
/* ------------------------------------------------------------------------------------------- */
/* Unzip */
/* ------------------------------------------------------------------------------------------- */
function findEocd(fd, size) {
// The EOCD sits at the very end unless there is a trailing comment, which is capped at 64KB.
const want = Math.min(size, 65557);
const buf = Buffer.alloc(want);
fs.readSync(fd, buf, 0, want, size - want);
for (let i = buf.length - 22; i >= 0; i--) {
if (buf.readUInt32LE(i) === EOCD_SIG) {
// ZIP64 would put the real values in a separate record and leave 0xffffffff here. The payload
// is nowhere near those limits, but a silent misparse would be far worse than a clear refusal.
if (i >= 20 && buf.readUInt32LE(i - 20) === ZIP64_EOCD_LOCATOR_SIG) {
throw new Error('ZIP64 archives are not supported by this installer');
}
return {
entries: buf.readUInt16LE(i + 10),
cdSize: buf.readUInt32LE(i + 12),
cdOffset: buf.readUInt32LE(i + 16),
};
}
}
throw new Error('not a zip file (no end-of-central-directory record)');
}
/*
* Reject anything that would write outside the destination.
*
* "Zip slip": an entry named ../../etc/something escapes the extraction root. Nothing we build
* contains such a name, but this unpacks a file fetched over the network onto a device in someone
* else's building, and validating is two lines.
*/
function safeJoin(destDir, name) {
if (!name || path.isAbsolute(name) || /^[A-Za-z]:/.test(name)) return null;
const full = path.resolve(destDir, name);
const root = path.resolve(destDir) + path.sep;
return (full + path.sep).startsWith(root) ? full : null;
}
/*
* Extract, yielding to the event loop as it goes.
*
* A synchronous loop over 9,000+ files would be simpler, and on this hardware it would freeze the
* page for the entire extraction — the one surface that can report what is happening. Handing
* control back every so often keeps the screen alive and costs nothing measurable.
*/
async function unzip(zipPath, destDir, onProgress) {
const fd = fs.openSync(zipPath, 'r');
try {
const size = fs.fstatSync(fd).size;
const eocd = findEocd(fd, size);
const cd = Buffer.alloc(eocd.cdSize);
fs.readSync(fd, cd, 0, eocd.cdSize, eocd.cdOffset);
const localHeader = Buffer.alloc(30);
let done = 0;
let skipped = 0;
let p = 0;
for (let n = 0; n < eocd.entries; n++) {
if (p + 46 > cd.length || cd.readUInt32LE(p) !== CD_SIG) {
throw new Error('corrupt central directory at entry ' + n);
}
const method = cd.readUInt16LE(p + 10);
const expectedCrc = cd.readUInt32LE(p + 16);
const compressedSize = cd.readUInt32LE(p + 20);
const nameLen = cd.readUInt16LE(p + 28);
const extraLen = cd.readUInt16LE(p + 30);
const commentLen = cd.readUInt16LE(p + 32);
const localOffset = cd.readUInt32LE(p + 42);
const name = cd.toString('utf8', p + 46, p + 46 + nameLen);
p += 46 + nameLen + extraLen + commentLen;
const target = safeJoin(destDir, name);
if (!target) { skipped++; continue; }
if (name.endsWith('/')) {
fs.mkdirSync(target, { recursive: true });
} else {
// The local header's extra field can differ in length from the central one, so the data
// offset has to come from the local header — not from the central directory's copy.
fs.readSync(fd, localHeader, 0, 30, localOffset);
if (localHeader.readUInt32LE(0) !== LOCAL_SIG) {
throw new Error('corrupt local header for ' + name);
}
const dataAt = localOffset + 30 + localHeader.readUInt16LE(26) + localHeader.readUInt16LE(28);
const raw = Buffer.alloc(compressedSize);
if (compressedSize > 0) fs.readSync(fd, raw, 0, compressedSize, dataAt);
let data;
if (method === 0) data = raw; // STORED — the whole point
else if (method === 8) data = zlib.inflateRawSync(raw);
else throw new Error('unsupported compression method ' + method + ' for ' + name);
/*
* Verify the CRC the archive already carries.
*
* Skipping this was a real gap: a corrupted or short-read file lands on disk looking
* perfectly normal and only surfaces much later as something baffling - a "SyntaxError:
* Invalid or unexpected token" from a file nobody edited, hundreds of files after the actual
* damage. The checksum is right there in the central directory and costs a pass over bytes
* we have already read.
*/
if (typeof zlib.crc32 === 'function' && expectedCrc !== 0) {
const actual = zlib.crc32(data);
if (actual !== expectedCrc) {
throw new Error('checksum mismatch extracting ' + name +
' (expected ' + expectedCrc.toString(16) + ', got ' + actual.toString(16) + ')');
}
}
fs.mkdirSync(path.dirname(target), { recursive: true });
// writeFileSync, never copyFileSync: the destination is exFAT, which has no permission bits,
// and anything that tries to set a mode there fails with EPERM.
fs.writeFileSync(target, data);
}
done++;
if (done % 100 === 0) {
if (onProgress) onProgress(done, eocd.entries);
await new Promise((r) => setImmediate(r));
}
}
if (onProgress) onProgress(done, eocd.entries);
return { files: done, skipped, entries: eocd.entries };
} finally {
fs.closeSync(fd);
}
}
/* ------------------------------------------------------------------------------------------- */
/* The installer */
/* ------------------------------------------------------------------------------------------- */
/*
* Install the payload into installDir, reporting progress through onState.
*
* Extraction goes to a staging directory and is renamed into place only once it has completed and
* been checked. Unpacking 9,000 files directly over the destination means an interruption leaves a
* half-installed tree that looks installed — server/server.js can easily be file 300 of 9,356 — and
* every subsequent boot would then skip the install and fail somewhere deep in a missing module.
*/
async function install(opts) {
const { url, installDir, onState } = opts;
const say = (phase, detail, pct) => { if (onState) onState({ phase, detail, pct }); };
const zipPath = path.join(installDir, 'server-payload.zip');
const staging = path.join(installDir, '.payload-staging');
const entry = path.join(installDir, 'server', 'server.js');
say('downloading', url, 0);
const { bytes } = await download(url, zipPath, (got, total) => {
const mb = (n) => Math.round(n / 1048576);
say('downloading',
total ? `${mb(got)}MB of ${mb(total)}MB` : `${mb(got)}MB`,
total ? Math.round((got / total) * 100) : null);
});
say('extracting', `${Math.round(bytes / 1048576)}MB downloaded`, 0);
fs.rmSync(staging, { recursive: true, force: true });
fs.mkdirSync(staging, { recursive: true });
const result = await unzip(zipPath, staging, (done, total) => {
say('extracting', `${done} of ${total} files`, Math.round((done / total) * 100));
});
// Verify before committing: the archive can be perfectly valid and still be the wrong archive.
if (!fs.existsSync(path.join(staging, 'server', 'server.js'))) {
throw new Error('payload unpacked but contains no server/server.js (' + result.files + ' files)');
}
/*
* Replacing the tree wholesale is only safe because runtime state lives OUTSIDE it: the launcher
* exports DATA_DIR so the database, uploads and certs sit in <install>/data, not in <install>/
* server. Refuse rather than proceed if that ever stops being true - this loop deletes what it
* replaces, and a payload update is not allowed to be a data-loss event.
*/
const dataDir = process.env.DATA_DIR || '';
const wouldDeleteState = dataDir && fs.readdirSync(staging)
.some((name) => (path.resolve(dataDir) + path.sep).startsWith(path.resolve(installDir, name) + path.sep));
if (wouldDeleteState) {
throw new Error('refusing to install: DATA_DIR (' + dataDir + ') is inside the payload tree');
}
say('installing', `${result.files} files`, null);
for (const name of fs.readdirSync(staging)) {
const from = path.join(staging, name);
const to = path.join(installDir, name);
fs.rmSync(to, { recursive: true, force: true });
fs.renameSync(from, to);
}
fs.rmSync(staging, { recursive: true, force: true });
// The archive is 71MB of duplicate on a device that will never need it again.
try { fs.unlinkSync(zipPath); } catch (e) { /* not worth failing over */ }
if (!fs.existsSync(entry)) throw new Error('install finished but ' + entry + ' is missing');
say('installed', `${result.files} files`, 100);
return result;
}
module.exports = { install, unzip, download };