mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
A BrightSign consultant ran our v1.9.29-rc2 autorun.zip through BSN.cloud's automated deployment. The archive reached the player and then could not be opened — reported as invalid. Two causes, both ours. 1. COMPRESSION. We built with default deflate. The player bootstrap extracts autozip.brs by itself before any script runs, and roBrightPackage supports a specific set of methods, of which "no compression" is the universally safe one. Both builders now store: scripts/build-autorun-zip.sh passes -0, and the server-side package builder used archiver level 9 — maximum deflate — so EVERY self-update package it produced would have failed the same way, silently and in the field. 2. THE UNPACK API. We used roUnzip; BrightSign's own tooling uses roBrightPackage. Converted in autozip.brs and in the self-update path. This is the failure mode worth naming: a compressed package uploads, downloads and deploys perfectly, then fails to open on the player. It reads as a broken deployment rather than a broken zip, so it gets debugged everywhere except where the bug is. Both builders now ASSERT the property rather than trusting the flag — the build script walks `unzip -v` and refuses a compressed member, and a test walks the local file headers of the server-built package checking method 0. Verified by negative control: re-enabling compression fails the test. Also adopted the shipped volume-discovery pattern in autozip.brs — probe USB1:/SD:/SSD:/FLASH: for the archive instead of guessing two volumes. The unit that drove this port boots from FLASH because its card interface is dead, and extracting to a volume that does not exist fails silently. 1056 pass. Reported by giyokun, who was right about both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
125 lines
5.2 KiB
JavaScript
125 lines
5.2 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.
|
|
let cached = null; // { version, sha256, size, buffer }
|
|
|
|
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.
|
|
*/
|
|
function buildZip() {
|
|
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');
|
|
// 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() {
|
|
if (cached) return cached;
|
|
const version = readVersion();
|
|
if (!version) return null;
|
|
try {
|
|
const buffer = await buildZip();
|
|
cached = {
|
|
version,
|
|
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
|
size: buffer.length,
|
|
buffer
|
|
};
|
|
return cached;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/* Test seam: drop the cache so a changed file is picked up without a restart. */
|
|
function _reset() { cached = null; }
|
|
|
|
module.exports = { getPackage, _reset, PACKAGE_FILES };
|