Compare commits

..

No commits in common. "main" and "v1.9.35" have entirely different histories.

65 changed files with 71 additions and 6698 deletions

View file

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

View file

@ -85,15 +85,6 @@ jobs:
./scripts/build-autorun-zip.sh -o autorun.zip ./scripts/build-autorun-zip.sh -o autorun.zip
ls -la autorun.zip ls -la autorun.zip
# A published SBOM is what turns "we track licences" into something a customer or an
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
- name: Generate SBOM (production dependencies)
run: |
( cd server && npm ci --omit=dev )
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
ls -la screentinker-sbom-*.cdx.json
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh) - name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
run: | run: |
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz" OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
@ -163,7 +154,6 @@ jobs:
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)." echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)." echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
} > RELEASE_NOTES.md } > RELEASE_NOTES.md
cat RELEASE_NOTES.md cat RELEASE_NOTES.md
@ -181,7 +171,6 @@ jobs:
--notes-file RELEASE_NOTES.md \ --notes-file RELEASE_NOTES.md \
"${TARBALL}" \ "${TARBALL}" \
autorun.zip \ autorun.zip \
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
tizen/ScreenTinker.wgt tizen/ScreenTinker.wgt
docker: docker:

7
.gitignore vendored
View file

@ -59,10 +59,3 @@ audit/
# Local SQLite artifacts (any extension the tooling might produce) # Local SQLite artifacts (any extension the tooling might produce)
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset)
sbom/
*.cdx.json
# Build artefacts: the player packages are built by scripts/, never committed.
brightsign/*.zip

View file

@ -1,39 +1,5 @@
# Changelog # Changelog
## 1.9.36
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
### Fixed — 1.9.35 would not start on a server collecting install statistics
A server with install-statistics collection switched on could not start 1.9.35. It threw
`ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
and a service manager configured to restart it would do so in a loop.
**Almost nobody is affected.** The fault is inside a block that only runs when a server is configured
to *collect* statistics from other installs — not when it merely reports its own. That is a single
deployment, not a normal install. If you have never set `TELEMETRY_COLLECTOR`, 1.9.35 runs correctly
and this release changes nothing for you.
The cause was a reference to the database resolved when the file loaded rather than when the request
arrived, in code that had been moved earlier in the same release.
### Changed — the startup check now covers configuration only one deployment uses
The fault above shipped through a full test suite and every CI job green, because the affected block
is switched on by configuration that no test set. It had never executed anywhere except the one
server that turns it on.
The startup smoke check now boots with that configuration enabled and confirms the routes it adds
actually answer. Code that only one deployment runs is exactly the code an automated check has to
exercise, and it now does.
### Upgrading
No migrations, no configuration changes, and no dependency changes from 1.9.35 — this release only
alters when one value is read. Upgrading from 1.9.34 or earlier, the 1.9.35 note still applies:
`npm ci --omit=dev` is required in both directions, which `scripts/upgrade.sh` already runs.
## 1.9.35 ## 1.9.35
A maintenance release. Two faults where the product was working correctly and still looked broken to A maintenance release. Two faults where the product was working correctly and still looked broken to

View file

@ -1 +1 @@
1.9.36 1.9.35

View file

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

View file

@ -1,45 +0,0 @@
{
"_comment": [
"Licence policy for everything on the Android release runtime classpath — i.e. everything that",
"can end up inside the APK customers install.",
"",
"scripts/android-license-check.js resolves the real classpath and checks it against this file.",
"An artifact that appears in neither 'artifacts' nor 'groups' FAILS: a new transitive dependency",
"must be looked at by a person before it ships, which is exactly how org.json:json:20090211 got",
"into the APK unnoticed in the first place.",
"",
"Record what you verified in 'evidence' — the point is to be able to answer 'how do you know?'"
],
"groups": {
"androidx": { "license": "Apache-2.0", "evidence": "AndroidX / Jetpack, Apache-2.0 across the board" },
"com.google.android.material": { "license": "Apache-2.0", "evidence": "Material Components for Android" },
"com.google.code.gson": { "license": "Apache-2.0", "evidence": "google/gson LICENSE" },
"com.google.crypto.tink": { "license": "Apache-2.0", "evidence": "google/tink LICENSE" },
"com.google.errorprone": { "license": "Apache-2.0", "evidence": "google/error-prone LICENSE" },
"com.google.guava": { "license": "Apache-2.0", "evidence": "google/guava LICENSE" },
"com.google.j2objc": { "license": "Apache-2.0", "evidence": "google/j2objc LICENSE" },
"com.squareup.okhttp3": { "license": "Apache-2.0", "evidence": "square/okhttp LICENSE" },
"com.squareup.okio": { "license": "Apache-2.0", "evidence": "square/okio LICENSE" },
"org.checkerframework": { "license": "MIT", "evidence": "checker-framework, MIT for the qualifiers" },
"org.jetbrains": { "license": "Apache-2.0", "evidence": "JetBrains annotations" },
"org.jetbrains.kotlin": { "license": "Apache-2.0", "evidence": "Kotlin stdlib" },
"org.jetbrains.kotlinx": { "license": "Apache-2.0", "evidence": "kotlinx coroutines" },
"io.socket": { "license": "MIT", "evidence": "socket.io-client-java LICENSE (MIT)" }
},
"artifacts": {},
"denied": {
"org.json:json": {
"why": "JSON Licence — the 'shall be used for Good, not Evil' clause. Not OSI-approved, non-free per Debian and Fedora, Apache Category X. Arrives transitively via socket.io-client and was previously packaged into the APK in full (19 classes). Excluded in app/build.gradle.kts; Android provides org.json in the platform from API 1 and minSdk is 24, so nothing is lost."
}
},
"denied_licenses": [
{ "match": "AGPL", "why": "network copyleft" },
{ "match": "GPL", "why": "strong copyleft in a commercially distributed binary" },
{ "match": "SSPL", "why": "not OSI-approved, service-scope obligations" },
{ "match": "JSON", "why": "field-of-use restriction" }
]
}

View file

@ -1,189 +0,0 @@
' ScreenTinker SERVER on a BrightSign player.
'
' TWO OBJECTS, TWO JOBS:
' roNodeJs - runs the server. A real Node process.
' roHtmlWidget - shows the diagnostic screen. Just a browser, pointed at a local page.
'
' ⚠️ THE SERVER USED TO LIVE INSIDE THE WIDGET, AND THAT COST FOUR BOOT FAILURES.
'
' A widget with nodejs_enabled is a Node context inside an Electron renderer, and it is NOT Node:
'
' 1. shebangs are not stripped, so any `#!/usr/bin/env node` file fails to compile with
' "Failed to construct 'ContextifyScript': Invalid or unexpected token"
' 2. require() of an ESM-only package is unsupported, which plain Node 24 handles
' 3. setInterval is the DOM's - it returns a NUMBER, so `setInterval(...).unref()` throws
' 4. worker_threads cannot create a thread at all: "The V8 platform used by this instance of
' Node does not support creating Workers"
'
' Every one of those is invisible to a test on a developer machine, because that test runs on real
' Node. BrightSign's own dev-cookbook is unambiguous about which object to use:
'
' "Use roNodeJs if you need a long running background process or have more complex needs.
' Use roHtmlWidget with Node.js enabled for browser-based apps."
' "You can use this for long running processes like gathering metrics or running a web server."
'
' Their cra-template examples are exactly this shape - server in roNodeJs, widget pointed at
' localhost. A server is not a browser-based app.
'
' ⚠️ IT ALSO FIXES THE LIFECYCLE PROBLEM, which was the original objection to running a server on
' this hardware at all. In a widget the server shares the PAGE's life: a load error, a watchdog
' trip or a deploy tears it down mid-write, and an open SQLite WAL goes with it. roNodeJs "will run
' in the background uninterrupted".
'
' NO NATIVE CODE is involved either way: the server reaches SQLite through node:sqlite (built into
' the Node that BrightSignOS 10 ships) via server/db/sqlite-compat.js, so the same bundle runs on
' x86_64 and on this aarch64 player.
Sub Main()
msgPort = CreateObject("roMessagePort")
root$ = StorageRoot()
print "[st-server] volume "; root$
' The server writes its database, uploads and certs under here. On the XT245 this is SSD: - the
' 128GB NVMe - which is what makes any of this reasonable. bs-server-boot.js exports DATA_DIR as
' <its own directory>/data, deliberately OUTSIDE the payload tree, so a payload update replaces
' the code without deleting the data.
CreateDirectory(root$ + "/data")
' ------------------------------------------------------------------------------------------
' 1. The server - only if this player has been told to be one.
' ------------------------------------------------------------------------------------------
' ⚠️ OFF UNLESS ASKED. A fleet gets one package; 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 be invisible until two of them fought over the same
' displays. A device with no config file, an unreadable one, or one that says 0 stays a player.
serverEnabled = ServerEnabled(root$)
print "[st-server] local server enabled: "; serverEnabled
' Only three keys exist here: message_port, node_arguments, arguments. An invented `env:` key is
' what killed the first attempt at this file, with nothing but "Load or runtime error in
' autorun. Forcing recovery." to go on - and it sent me to the widget for the wrong reason.
' Anything the server needs to be told goes in DATA_DIR/server.env, which it reads itself.
node = invalid
if serverEnabled then
node = CreateObject("roNodeJs", "bs-server-boot.js", { message_port: msgPort })
if node = invalid then
print "[st-server] FAILED: could not launch the node process"
else
print "[st-server] node process launched"
end if
end if
' ------------------------------------------------------------------------------------------
' 2. The screen.
' ------------------------------------------------------------------------------------------
v = CreateObject("roVideoMode")
w% = 1920
h% = 1080
if v <> invalid then
w% = v.GetResX()
h% = v.GetResY()
end if
rect = CreateObject("roRectangle", 0, 0, w%, h%)
' Spelled out rather than casting the boolean: this file cannot be run anywhere but on the
' player, so it is not the place for a clever conversion nobody can check.
serverParam$ = "0"
if serverEnabled then serverParam$ = "1"
' NOTE what is NOT here: nodejs_enabled. The page no longer requires anything - it polls the
' server process over HTTP - so it can be an ordinary browser page. One less hybrid context.
config = {
' The page cannot discover this for itself: when the server is off there is no status
' listener to ask, and "nothing is answering" would render as a fault rather than as a
' deliberate setting.
url: "file:///" + LCase(StripColon(root$)) + ":/node-server.html?server=" + serverParam$
javascript_enabled: true
brightsign_js_objects_enabled: true
storage_path: root$ + "/widget-cache"
storage_quota: 1073741824.0
port: msgPort
mouse_enabled: false
}
widget = CreateObject("roHtmlWidget", rect, config)
if widget = invalid then
print "[st-server] FAILED: could not create the diagnostic widget (the server still runs)"
else
widget.Show()
print "[st-server] diagnostic screen shown"
end if
' Stay alive and report. The script must not return, or the player treats it as an autorun that
' ended and forces recovery. Both objects also have to stay in scope - dropping the roNodeJs
' reference would take the server down with it.
while true
ev = Wait(0, msgPort)
if type(ev) = "roHtmlWidgetEvent" then
d = ev.GetData()
if type(d) = "roAssociativeArray" and d.reason <> invalid then
print "[st-server] widget: "; d.reason
' A page that fails to load leaves a black screen and no explanation anywhere.
if d.reason = "load-error" then print "[st-server] the page failed to load: "; d.message
end if
else if type(ev) = "roNodeJsEvent" then
' Whatever the node process sends back over the message port. The server does not rely
' on this channel - it reports over HTTP so the screen works across page reloads - but
' printing it puts node's own messages on the serial console, which is the only window
' into a boot that fails before the screen is up.
print "[st-server] node: "; ev.GetData()
end if
end while
End Sub
'*******************************************************************************************
Function ServerEnabled(root$ As String) As Boolean
'*******************************************************************************************
' st-config.json on the storage root, e.g. {"server": 1}
'
' Deliberately at the root rather than inside data/: it is what an operator drops in over the
' DWS, and autozip never writes it, so a re-provision cannot silently switch a site's server
' off - or on.
'
' Absent, unparseable, or anything other than an affirmative value means DISABLED. There is no
' reading of a broken config file that should end with a device deciding to host a server.
txt$ = ReadAsciiFile(root$ + "/st-config.json")
if txt$ = "" then return false
cfg = ParseJSON(txt$)
if cfg = invalid then
print "[st-server] st-config.json is not valid JSON - server stays disabled"
return false
end if
if type(cfg) <> "roAssociativeArray" then return false
v = cfg.server
if v = invalid then return false
' Accept the shapes a human actually writes: 1, true, "1", "true", "yes", "on".
if type(v) = "Boolean" then return v
if type(v) = "Integer" then return v <> 0
if type(v) = "roInt" then return v <> 0
if type(v) = "String" or type(v) = "roString" then
low$ = LCase(v)
return low$ = "1" or low$ = "true" or low$ = "yes" or low$ = "on"
end if
return false
End Function
'*******************************************************************************************
Function StripColon(v As String) As String
'*******************************************************************************************
' "SSD:" -> "SSD". The url form wants file:///ssd:/... and StorageRoot() hands back "SSD:".
if Right(v, 1) = ":" then return Left(v, Len(v) - 1)
return v
End Function
'*******************************************************************************************
Function StorageRoot() As String
'*******************************************************************************************
' Which volume did we come up from? The server's data has to live on the same one. On the XT245
' the card slot is dead and the priority order (flash, usb1, sd, sd2, ssd) resolves to SSD: with
' nothing else present - but probe rather than assume, because extracting to a volume that does
' not exist silently does nothing.
for each v in ["SSD:", "SD:", "USB1:", "FLASH:"]
if CreateObject("roReadFile", v + "/node-server.html") <> invalid then return v
end for
return "SSD:"
End Function

View file

@ -1,298 +0,0 @@
'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 };

View file

@ -1,485 +0,0 @@
'use strict';
/*
* The Node half of "ScreenTinker server, running on the player it serves".
*
* BrightScript launches this with roNodeJs: a REAL Node process, not an roHtmlWidget. That
* distinction is the whole reason this file got simpler. Inside a widget the server is a Node
* context inside an Electron renderer, and four separate things break shebangs are not stripped,
* require(ESM) is unsupported, setInterval is the DOM's and returns a number, and worker_threads
* cannot create a thread at all. BrightSign's own notes say to use roNodeJs "for long running
* processes like gathering metrics or running a web server", and roHtmlWidget "for browser-based
* apps". The server is the former. Their cra-template examples do exactly this: server in
* roNodeJs, widget pointed at localhost.
*
* It also fixes the durability problem: a widget's server shares the PAGE's lifecycle, so a load
* error or a deploy tears down the server and its open SQLite WAL with it. roNodeJs "will run in
* the background uninterrupted".
*
* This file is the only thing between that arrangement and the ordinary server:
*
* 1. capture console output into a ring buffer, so the screen can show a log tail on a box with
* no monitor attached to its serial port,
* 2. post a status frame IP, disk, memory, uptime, recent log to BrightScript on a timer,
* 3. start the real server unmodified.
*
* It must never be the reason the server fails to boot. Everything here is wrapped: a broken
* status frame is worth less than a running server, and on a device with no console the difference
* between "crashed" and "started but silent" is invisible.
*/
const os = require('os');
const net = require('net');
const http = require('http');
/*
* GIVE THE PAGE NODE'S TIMERS.
*
* This runs inside an roHtmlWidget, which is a BROWSER as well as a Node context, and the browser
* wins for globals. The DOM's setInterval returns a NUMBER; Node's returns a Timeout object with
* .unref(). So ordinary server code that has always worked dies here:
*
* TypeError: setInterval(...).unref is not a function
* at server/routes/widgets.js:359
*
* Swapping the globals for node:timers fixes every call site at once - the two unguarded ones and
* the sixteen written as `if (t.unref) t.unref()`, which on this platform were silently NOT
* unreffing. Done here rather than in the server so the product keeps one timer idiom, and because
* this is a property of the host, not of the code.
*
* The same trap as the shebang and as require(ESM): plain Node is not what this runs on, and a
* local test under plain Node cannot see any of it.
*/
const nodeTimers = require('timers');
for (const name of ['setTimeout', 'setInterval', 'setImmediate',
'clearTimeout', 'clearInterval', 'clearImmediate']) {
if (typeof nodeTimers[name] === 'function') globalThis[name] = nodeTimers[name];
}
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------------------------
// Talking to BrightScript
//
// roNodeJs delivers whatever we write to stdout as a message when it is valid JSON on one line;
// the BrightScript side reads it off the message port. Newline-delimited JSON keeps the framing
// trivial on a side that has no JSON streaming parser.
// ---------------------------------------------------------------------------------------------
// Kept for the case where this is run directly with `node bs-server-boot.js` (how it is tested on
// a desktop). Inside the widget nothing reads stdout — the PAGE calls status() and renders it, so
// this is a debugging aid, not the channel.
function post(obj) {
try { if (process.stdout && process.stdout.write) process.stdout.write(JSON.stringify(obj) + '\n'); }
catch (e) { /* never fatal */ }
}
// What the screen shows. Set when the server cannot start, so the page can say so instead of
// displaying a frozen "starting..." forever.
let fatalMessage = null;
// ---------------------------------------------------------------------------------------------
// The log ring
//
// Bounded on purpose: this runs for months. An unbounded array fed by a chatty server is a slow
// leak on the one device nobody is watching, which is the same reasoning the bridge's pending
// queue uses.
// ---------------------------------------------------------------------------------------------
const LOG_MAX = 200;
const logRing = [];
function remember(level, args) {
try {
const line = args.map((a) => (typeof a === 'string' ? a : require('util').inspect(a, { depth: 1 }))).join(' ');
for (const part of line.split('\n')) {
if (!part.trim()) continue;
logRing.push({ t: Date.now(), level, m: part.slice(0, 300) });
if (logRing.length > LOG_MAX) logRing.shift();
}
} catch (e) { /* logging must not throw */ }
}
for (const level of ['log', 'info', 'warn', 'error']) {
const original = console[level].bind(console);
console[level] = (...args) => { remember(level, args); original(...args); };
}
// ---------------------------------------------------------------------------------------------
// What the screen shows
// ---------------------------------------------------------------------------------------------
function firstIPv4() {
try {
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
if (/^(lo|docker|veth)/.test(name)) continue;
for (const a of addrs || []) {
// Node 18+ reports family as the string 'IPv4'; older builds used the number 4. The player
// is on 24, but this costs one comparison and removes a version dependency.
if ((a.family === 'IPv4' || a.family === 4) && !a.internal) return a.address;
}
}
} catch (e) { /* fall through */ }
return null;
}
/* Disk usage for the volume the server actually writes to, via statfs. */
function diskFor(dir) {
try {
// The data directory may not exist on the first frame — the server creates it during boot, and
// the whole point of that first frame is to show something before the server is up. statfs on
// any path on the same volume gives the same answer, so fall back to where we are installed.
const target = fs.existsSync(dir) ? dir : __dirname;
const s = fs.statfsSync(target);
const total = s.blocks * s.bsize;
const free = s.bavail * s.bsize;
return { totalMb: Math.round(total / 1048576), freeMb: Math.round(free / 1048576),
usedPct: total ? Math.round(((total - free) / total) * 100) : null };
} catch (e) { return null; }
}
function dbBytes(dir) {
let sum = 0;
try {
for (const f of fs.readdirSync(dir)) {
if (!/\.db(-wal|-shm)?$/.test(f)) continue;
try { sum += fs.statSync(path.join(dir, f)).size; } catch (e) { /* skip */ }
}
} catch (e) { return null; }
return Math.round(sum / 1048576);
}
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
/*
* EXPORT IT, do not merely compute it. This line is load-bearing twice over.
*
* server/config.js reads process.env.DATA_DIR and falls back to its own __dirname, so without this
* the server puts its database, uploads and .jwt_secret INSIDE server/ - inside the payload tree.
* That tree is deleted and replaced wholesale on the next payload update, so the first update would
* have silently destroyed the database, the uploaded content and the signing secret.
*
* It also made the diagnostic screen lie: it reported "database n/a" because it looked in
* DATA_DIR/db while the server was writing to server/db, which reads as "there is no database" when
* there is a perfectly good one a directory away.
*/
process.env.DATA_DIR = DATA_DIR;
/*
* Read at FRAME time, not at load time. server.env is applied below, after this module's constants
* would have been evaluated so a PORT captured here would show the default on screen while the
* server was actually listening on the configured one. The screen exists to tell an operator where
* to point a browser; a plausible wrong number is worse than no number.
*/
const currentPort = () => process.env.PORT || 3001;
/*
* Local configuration, seeded once and then owned by the device.
*
* The packager REFUSES to bundle a .env that guard exists because the first build of this package
* swept up the developer's real one along with a 33MB database and 105MB of uploads. So the package
* carries a template instead, and the first boot copies it into DATA_DIR, where it sits alongside
* the data: a package update replaces the code and leaves the operator's settings intact.
*
* Precedence is deliberate: anything already in process.env which is how autorun.brs passes
* DATA_DIR and PORT through roNodeJs wins. The file fills in what the launcher did not say.
*
* No secrets live here. The JWT signing secret is generated per install into
* DATA_DIR/certs/.jwt_secret by server/config.js, so every player gets its own; one shipped in a
* package would be identical on every device that installed it.
*/
function loadLocalEnv() {
const template = path.join(__dirname, 'server.env.example');
let source = path.join(DATA_DIR, 'server.env');
try {
if (!fs.existsSync(source)) {
if (!fs.existsSync(template)) return;
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
/*
* NOT fs.copyFileSync. On the player this failed with
* EPERM: operation not permitted, copyfile '...server.env.example' -> '.../server.env'
* copyFileSync does not merely copy bytes: it opens the destination and then fchmods it to
* match the source's mode. /storage/ssd is exFAT, which has no permission bits, so the
* chmod is refused. Writing the bytes ourselves never asks for a mode and works fine which
* is also why unpacking the zip onto the same volume was never a problem.
*/
fs.writeFileSync(source, fs.readFileSync(template));
remember('log', [`created ${source} from the template — edit it on the device`]);
} catch (e) {
/*
* Persisting is a convenience; APPLYING the configuration is not. Read the template directly
* rather than giving up, or a read-only data directory silently downgrades the server to
* defaults which is precisely what happened here: PORT=8080 never applied and the screen
* advertised :3001 while claiming to be running.
*/
remember('warn', ['could not persist server.env, using the packaged template',
String(e && e.message ? e.message : e)]);
source = template;
}
}
for (const line of fs.readFileSync(source, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 1) continue;
const k = t.slice(0, eq).trim();
// Already set by the launcher: leave it alone.
if (process.env[k] !== undefined) continue;
process.env[k] = t.slice(eq + 1).trim();
}
} catch (e) {
// A malformed config must not stop the server booting: it comes up on defaults and says so.
remember('error', ['could not read server.env', String(e && e.message ? e.message : e)]);
}
}
loadLocalEnv();
/*
* Turn off the native-dependency preflight.
*
* preflight-deps.js exists to catch better-sqlite3 compiled against the wrong NODE_MODULE_VERSION:
* it probes with a real `new Database(':memory:')`, runs `npm rebuild`, and hard-exits if the
* module still will not load. That is the correct behaviour on a normal install and it is exactly
* wrong here this build has no better-sqlite3 at all, by design, because it reaches SQLite
* through node:sqlite. Left on, it finds the module missing, tries to rebuild a package that is not
* in package.json, fails, and refuses to boot the server.
*
* Set here rather than relying on BrightScript to pass it: the check runs on require of the server,
* and a package that boots only when the launcher remembers an environment variable is a package
* that will eventually not boot.
*/
process.env.ST_SKIP_DEP_PREFLIGHT = '1';
/*
* Declared HERE, above statusFrame, and not down beside the installer that maintains it.
*
* `let` is hoisted but not initialised, so a reference before this line throws ReferenceError
* rather than reading undefined. statusFrame() is called on the first tick long before the
* install block further down so declaring it next to its logic put the whole boot in a temporal
* dead zone: one frame, one ReferenceError, no server, and a blank screen to debug it with.
*/
let installState = { phase: 'idle', detail: '', pct: null };
let lastLoggedInstall = null;
/*
* Is anything actually LISTENING?
*
* The screen showed http://192.168.1.46:8080 in large green type while the server was still
* downloading its own code, and again while it was dead from a failed require. An address that
* does not answer is worse than no address: someone reads it off the screen, the browser hangs,
* and the player looks broken in a way that has nothing to do with the real fault.
*
* So prove it rather than infer it - a real TCP connect to the port, on the same interval as the
* status frame. Cheap, and it cannot be fooled by the server having got halfway up.
*/
/*
* Has anyone created the first account yet?
*
* The screen has three states, and this is the one the server has to be asked about: a server that
* is up but has no users is not ready to show a player, it is waiting for someone to open the
* dashboard and create an admin. /api/auth/config answers it and is public by design.
*
* Asked HERE rather than from the page because the page is loaded from file:// - origin "null" -
* and the server sets no CORS headers on its own API. This process is already talking to it.
*
* null means "not known yet", which is deliberately distinct from false: the page must not flip to
* the player on a probe that has not answered.
*/
let needsSetup = null;
function probeSetup() {
if (!serving) { needsSetup = null; return; }
const req = http.request(
{ host: '127.0.0.1', port: Number(currentPort()), path: '/api/auth/config', timeout: 3000 },
(res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
try { needsSetup = !!JSON.parse(body).needsSetup; }
catch (e) { /* a malformed answer is not an answer */ }
});
});
req.on('error', () => { /* server not answering yet; leave the previous value */ });
req.on('timeout', () => req.destroy());
req.end();
}
let serving = false;
function probeListening() {
const port = Number(currentPort());
if (!port) { serving = false; return; }
const sock = net.connect({ host: '127.0.0.1', port });
const done = (ok) => { serving = ok; sock.destroy(); };
sock.setTimeout(1500);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false));
sock.once('error', () => done(false));
}
function statusFrame() {
const mem = process.memoryUsage();
return {
type: 'st-server-status',
ip: firstIPv4(),
port: currentPort(),
pid: process.pid,
node: process.versions.node,
uptimeSec: Math.round(process.uptime()),
rssMb: Math.round(mem.rss / 1048576),
heapMb: Math.round(mem.heapUsed / 1048576),
freeMemMb: Math.round(os.freemem() / 1048576),
loadAvg: os.loadavg().map((n) => Math.round(n * 100) / 100),
disk: diskFor(DATA_DIR),
install: installState,
serving,
needsSetup,
dbMb: dbBytes(path.join(DATA_DIR, 'db')),
log: logRing.slice(-14),
};
}
// ---------------------------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------------------------
post({ type: 'st-server-boot', node: process.versions.node, arch: process.arch, dataDir: DATA_DIR });
// A frame straight away so the screen is never blank while the server warms up, then on a timer.
// 2s is a compromise: fast enough to watch a boot, slow enough that a 3-core player is not being
// asked to serialise state constantly while it is also serving.
const timer = setInterval(() => { probeListening(); probeSetup(); post(statusFrame()); }, 2000);
if (typeof timer.unref === 'function') timer.unref();
post(statusFrame());
process.on('uncaughtException', (e) => {
remember('error', ['UNCAUGHT', e && e.stack ? e.stack : String(e)]);
post(statusFrame());
fatalMessage = String(e && e.message ? e.message : e);
post({ type: 'st-server-fatal', message: fatalMessage });
// NOT process.exit() any more. Inside a widget that would take the page down with it, losing the
// one surface that can report what went wrong. The screen shows FAILED and the reason instead.
});
process.on('unhandledRejection', (e) => remember('error', ['UNHANDLED REJECTION', String(e)]));
/*
* Is the server payload here and if not, go and get it.
*
* The boot files and the ~71MB of server + node_modules ship separately: BrightSignOS cannot open an
* autorun.zip that big (it renames it to autorun.zip_invalid and forces recovery), while a 32KB one
* boots fine. So this downloads and unpacks the rest itself, which Node has no trouble with, and
* means the payload can also be updated without re-provisioning the device.
*/
const SERVER_ENTRY = path.join(__dirname, 'server', 'server.js');
function startServer() {
try {
require('./server/server.js');
} catch (e) {
remember('error', ['server failed to start', e && e.stack ? e.stack : String(e)]);
fatalMessage = String(e && e.message ? e.message : e);
post(statusFrame());
post({ type: 'st-server-fatal', message: fatalMessage });
}
}
if (fs.existsSync(SERVER_ENTRY)) {
installState = { phase: 'installed', detail: 'already present', pct: 100 };
startServer();
} else {
/*
* Where the payload comes from. Configurable because a self-hosted install will not be fetching
* from ours this is the one address the device cannot discover for itself, since the page is
* loaded from a file:// URL and the launcher has no environment to pass it through.
*/
const payloadUrl = process.env.ST_PAYLOAD_URL
|| 'https://alpha.screentinker.com/scripts/server-payload.zip';
installState = { phase: 'starting', detail: payloadUrl, pct: null };
remember('log', ['server payload not installed — fetching ' + payloadUrl]);
let installer;
try {
installer = require(path.join(__dirname, 'bs-payload-install.js'));
} catch (e) {
fatalMessage = 'installer missing: ' + String(e && e.message ? e.message : e);
remember('error', [fatalMessage]);
}
if (installer) {
installer.install({
url: payloadUrl,
installDir: __dirname,
onState: (st) => {
installState = st;
/*
* One line per PHASE CHANGE or per quarter of progress - not per tick.
*
* The first version logged when pct was 0, 100 or null, which reads as "the interesting
* moments" and is not: a download fires its progress callback on every chunk, so once it
* reached 100% it logged on every one of them. The 200-entry ring filled with dozens of
* copies of "downloading: 73MB of 73MB" and pushed out everything worth reading.
*/
const bucket = st.pct === null || st.pct === undefined ? 'x' : Math.floor(st.pct / 25);
const key = st.phase + ':' + bucket;
if (key !== lastLoggedInstall) {
lastLoggedInstall = key;
remember('log', [st.phase + ': ' + st.detail]);
}
},
}).then((r) => {
remember('log', ['payload installed (' + r.files + ' files) — starting the server']);
startServer();
}).catch((e) => {
installState = { phase: 'failed', detail: String(e && e.message ? e.message : e), pct: null };
fatalMessage = 'could not install the server payload: ' + installState.detail;
remember('error', [fatalMessage]);
post({ type: 'st-server-fatal', message: fatalMessage });
});
}
}
/*
* The diagnostic page lives in a DIFFERENT PROCESS now, so it cannot require() this file the way
* it did when both ran inside the widget. It polls this instead.
*
* Deliberately a separate tiny listener rather than a route on the real server: its entire job is
* to report on a server that is downloading, extracting, or failing to start exactly the states
* in which the real server cannot answer anything. It binds immediately, before the payload exists.
*/
function status() {
const f = statusFrame();
f.fatal = fatalMessage;
return f;
}
const STATUS_PORT = Number(process.env.ST_STATUS_PORT || 8182);
try {
const statusServer = http.createServer((req, res) => {
// The page is loaded from file://, whose origin is "null" - it needs CORS to read this at all.
res.writeHead(200, {
'content-type': 'application/json',
'access-control-allow-origin': '*',
'cache-control': 'no-store',
});
let body;
try { body = JSON.stringify(status()); }
catch (e) { body = JSON.stringify({ fatal: 'status unavailable: ' + (e && e.message) }); }
res.end(body);
});
statusServer.on('error', (e) => {
// Losing the screen must never cost us the server.
remember('error', ['status listener failed', String(e && e.message ? e.message : e)]);
});
/*
* LOOPBACK ONLY. Its only consumer is node-server.html running on this same device.
*
* Bound to every interface - which is what listen(port) does - it answered from anywhere on the
* customer's LAN with the install progress, disk usage, the device's own address and a tail of
* the server's console. That last one is the problem: a log tail carries whatever the server
* last printed, which is not a thing to hand to an unauthenticated caller on a network we do
* not control.
*/
statusServer.listen(STATUS_PORT, '127.0.0.1',
() => remember('log', ['status listener on 127.0.0.1:' + STATUS_PORT]));
if (statusServer.unref) statusServer.unref();
} catch (e) {
remember('error', ['could not start the status listener', String(e && e.message ? e.message : e)]);
}
module.exports = { status };

View file

@ -1,256 +0,0 @@
<!doctype html>
<!--
ScreenTinker server diagnostics, displayed on the player.
THE SERVER IS NOT IN THIS PAGE. It runs as a separate roNodeJs process; this is only the screen.
It used to host the server, and that was a mistake worth recording. A Node context inside an
Electron renderer is not Node: shebangs are not stripped, require(ESM) is unsupported,
setInterval is the DOM's and returns a number rather than a Timeout, and worker_threads cannot
create a thread at all. Four separate boot failures, each invisible to a local test because a
local test runs on real Node. BrightSign's own guidance is explicit - roNodeJs for "a long
running process like ... running a web server", roHtmlWidget for "browser-based apps".
The other half of that mistake was lifecycle: a server inside the page dies with the page, and
takes an open SQLite WAL with it. roNodeJs runs in the background uninterrupted.
So this page does one job - show what the server process reports, including while it is still
downloading itself - and it does that over HTTP because the two are no longer in one process.
-->
<meta charset="utf-8">
<title>ScreenTinker server</title>
<style>
html, body { margin: 0; height: 100%; background: #0b0f1a; color: #e6edf7;
font: 22px/1.5 ui-monospace, "DejaVu Sans Mono", monospace; }
.wrap { padding: 40px 56px; }
h1 { font-size: 34px; margin: 0 0 4px; letter-spacing: .5px; }
.sub { color: #7d8da5; margin-bottom: 28px; }
.url { font-size: 44px; color: #4ade80; margin: 18px 0 26px; word-break: break-all; }
table { border-collapse: collapse; margin-bottom: 26px; }
td { padding: 3px 26px 3px 0; }
td.k { color: #7d8da5; }
.bad { color: #f87171; }
/* Not green: green reads as "ready", and it is not ready. */
.url.pending { color: #7d8da5; }
/* The player is a full-screen layer ABOVE the diagnostics, shown only once the server is
genuinely ready. Diagnostics stay mounted underneath so they can be revealed instantly. */
#player { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: none;
background: #000; z-index: 10; }
#log { background: #060911; border: 1px solid #1e2a3d; border-radius: 6px; padding: 14px 18px;
font-size: 17px; line-height: 1.45; height: 34vh; overflow: hidden; color: #9fb3cd;
white-space: pre-wrap; }
</style>
<iframe id="player" title="ScreenTinker player" allow="autoplay; fullscreen"></iframe>
<div class="wrap">
<h1>ScreenTinker server</h1>
<div class="sub" id="sub">starting&hellip;</div>
<div class="url" id="url">&mdash;</div>
<table>
<tr><td class="k">uptime</td><td id="uptime">&mdash;</td>
<td class="k">memory</td><td id="mem">&mdash;</td></tr>
<tr><td class="k">disk</td><td id="disk">&mdash;</td>
<td class="k">database</td><td id="db">&mdash;</td></tr>
<tr><td class="k">node</td><td id="node">&mdash;</td>
<td class="k">load</td><td id="load">&mdash;</td></tr>
</table>
<div id="setupNote" style="display:none;font-size:26px;color:#fbbf24;margin:-10px 0 22px">
Open the address above and create the first account to finish setup.
</div>
<div id="log">waiting for the server&hellip;</div>
</div>
<script>
// Plain browser JavaScript. This page no longer needs nodejs_enabled: it does not require()
// anything, it just polls the server process. One less hybrid context to reason about.
(function () {
var el = function (id) { return document.getElementById(id); };
function fail(what, e) {
el('sub').innerHTML = '<span class="bad">' + what + '</span>';
el('log').textContent = String(e && e.stack ? e.stack : e);
}
/*
* The server runs in a DIFFERENT PROCESS now (roNodeJs), so this page cannot require() it.
*
* It used to: both halves lived inside one roHtmlWidget, and the page pulled status straight out
* of the module. Moving the server to roNodeJs is what makes it survive this page reloading, and
* gives it a real Node runtime instead of a renderer - at the cost that the two now have to talk.
* They talk over HTTP on a small port that the wrapper binds immediately, before the payload is
* even downloaded, because "downloading" and "failed to start" are exactly the states this screen
* exists to show.
*/
var STATUS_URL = 'http://127.0.0.1:8182/';
var last = null;
var lastError = null;
function poll() {
// XHR rather than fetch: this page is loaded from file://, and XHR's failure modes here are
// easier to report than a rejected promise with an opaque TypeError.
var xhr = new XMLHttpRequest();
xhr.open('GET', STATUS_URL + '?t=' + Date.now(), true);
xhr.timeout = 4000;
xhr.onload = function () {
try { last = JSON.parse(xhr.responseText); lastError = null; }
catch (e) { lastError = 'bad status payload'; }
paint();
};
xhr.onerror = function () { lastError = 'no answer from the server process'; paint(); };
xhr.ontimeout = function () { lastError = 'status request timed out'; paint(); };
try { xhr.send(); } catch (e) { lastError = String(e && e.message ? e.message : e); paint(); }
}
/*
* WHICH LAYER IS ON SCREEN.
*
* Three states, and the transitions between them are the whole point:
*
* installing / down / failed diagnostics. The operator can see why.
* up, but no account yet diagnostics, plus the address to go and create one. A player
* with no account to belong to has nothing to show, and hiding
* the address would leave the box unsetuppable - it has no
* keyboard.
* up, account exists the player, full screen.
*
* ⚠️ THE PLAYER IS AN IFRAME, NOT A NAVIGATION. Setting location.href would replace this
* document, and with it the poller that is the only thing able to notice the server failing
* later. As a layer, the diagnostics are always one style change away from being back on screen -
* which is exactly what should happen if the server dies at 3am.
*
* needsSetup === null means the probe has not answered yet, and is deliberately NOT treated as
* false: flipping to the player on an unanswered probe would show a blank player to an operator
* who is still waiting to be told where to sign up.
*/
/*
* WHICH LAYER BELONGS ON SCREEN. Pure, so the transition table can be tested - see
* server/test/brightsign-screen-state.test.js.
*
* 'diagnostics' installing, down, or failed. The operator can see why.
* 'setup' up, but nobody has created an account. Diagnostics PLUS the address to go
* and create one: a player with no account has nothing to show, and hiding the
* address would leave the box unsetuppable - it has no keyboard.
* 'player' up, and an account exists.
*
* needsSetup === null means the probe has not answered yet, and is deliberately NOT false:
* flipping to the player on an unanswered probe shows a blank player to someone who is still
* waiting to be told where to sign up.
*/
function screenState(s, serverEnabled) {
// Off on purpose is not the same as broken. Without this the page would poll a listener that
// was never started and report "no answer from the server process" - which reads as a fault
// and would send someone looking for one.
if (serverEnabled === false) return 'disabled';
if (!s || !s.serving || s.fatal) return 'diagnostics';
if (s.needsSetup === true) return 'setup';
if (s.needsSetup === false) return 'player';
return 'diagnostics';
}
/*
* ⚠️ THE PLAYER IS AN IFRAME, NOT A NAVIGATION. Setting location.href would replace this
* document and with it the poller - the only thing able to notice the server failing later. As a
* layer, the diagnostics are always one style change away from being back on screen, which is
* exactly what should happen if the server dies at 3am.
*/
var playerShown = false;
/*
* st-config.json decides whether this box runs a server; autorun.brs passes the answer through
* because with the server off there is no status listener to ask.
*/
var serverEnabled = !/[?&]server=0(&|$)/.test(String(location.search));
function applyLayer(s) {
var state = screenState(s, serverEnabled);
var frame = el('player');
if (state === 'player') {
if (!playerShown) {
// (Re)load on every transition INTO player. If the server had been down, whatever the
// player last rendered is an error page and it will not recover on its own.
frame.src = 'http://127.0.0.1:' + s.port + '/player/';
frame.style.display = 'block';
playerShown = true;
}
return true;
}
if (playerShown) {
// Blank the frame so a dead server is not being hammered by a player retrying behind an
// invisible layer.
frame.style.display = 'none';
frame.removeAttribute('src');
playerShown = false;
}
el('setupNote').style.display = state === 'setup' ? 'block' : 'none';
if (state === 'disabled') {
el('sub').textContent = 'local server disabled';
el('sub').className = 'sub';
el('url').textContent = 'set {"server": 1} in st-config.json to enable';
el('url').className = 'url pending';
el('log').textContent =
'This player is not running a ScreenTinker server.\n\n' +
'Exactly one device per site should host one. To make it this device, put\n' +
' {"server": 1}\n' +
'in st-config.json on the storage root and reboot.';
}
return false;
}
function paint() {
var s = last;
if (applyLayer(s)) return;
if (!s) {
// Before the first successful poll there is genuinely nothing to report. Say that, rather
// than paint zeros that look like a running server with no traffic.
el('sub').textContent = lastError ? ('waiting for the server process \u2014 ' + lastError)
: 'starting\u2026';
el('sub').className = 'sub';
el('url').textContent = 'starting\u2026';
el('url').className = 'url pending';
return;
}
// While the payload is coming down there is no server yet and nothing to be alarmed about, so
// say what is happening rather than showing a bare 'starting...' for the length of a 71MB fetch.
var inst = s.install || {};
var busy = inst.phase && inst.phase !== 'installed' && inst.phase !== 'idle' && inst.phase !== 'failed';
el('sub').className = 'sub' + (s.fatal ? ' bad' : '');
el('sub').textContent = s.fatal ? 'FAILED'
: busy ? (inst.phase + (inst.pct !== null && inst.pct !== undefined ? ' ' + inst.pct + '%' : '')
+ (inst.detail ? ' \u2014 ' + inst.detail : ''))
: 'running';
// Only show the address once something answers on it - see `serving` in bs-server-boot.js.
// Until then say what is actually happening, so nobody types in a URL that cannot work.
if (s.serving && s.ip) {
el('url').textContent = 'http://' + s.ip + ':' + s.port;
el('url').className = 'url';
} else {
el('url').textContent = !s.ip ? 'no network'
: busy ? 'installing\u2026'
: s.fatal ? 'not running' : 'starting\u2026';
el('url').className = 'url pending';
}
el('uptime').textContent = s.uptimeSec + 's';
el('mem').textContent = 'rss ' + s.rssMb + 'MB / free ' + s.freeMemMb + 'MB';
el('disk').textContent = s.disk
? (s.disk.freeMb + 'MB free of ' + s.disk.totalMb + 'MB (' + s.disk.usedPct + '% used)')
: 'n/a';
el('db').textContent = (s.dbMb === null || s.dbMb === undefined) ? 'n/a' : (s.dbMb + 'MB');
el('node').textContent = s.node;
el('load').textContent = s.loadAvg && s.loadAvg.length ? s.loadAvg[0] : 'n/a';
el('log').textContent = (s.log || []).map(function (l) { return l.m; }).join('\n');
}
paint();
if (serverEnabled) {
poll();
setInterval(poll, 2000);
} else {
// No listener was ever started, so polling would only manufacture errors.
applyLayer(null);
}
})();
</script>

View file

@ -1,36 +0,0 @@
# ScreenTinker server, running ON a BrightSign player.
#
# On first boot this file is copied to <DATA_DIR>/server.env and read from there. Edit THAT copy
# on the device — it lives with the data, so a package update replaces the code and leaves your
# settings alone.
#
# Anything the BrightScript launcher passes through roNodeJs wins over this file, so autorun.brs
# stays the authority for DATA_DIR and PORT.
#
# ⚠️ NO SECRETS IN HERE, and none are needed. The JWT signing secret is generated on first boot and
# persisted to <DATA_DIR>/certs/.jwt_secret (server/config.js), so every player gets its own. A
# secret shipped inside a package would be the same on every device that ever installs it, which is
# the same as having none.
# This is a self-hosted install: the first registered user becomes admin, and there is no billing.
SELF_HOSTED=true
HIDE_BILLING=true
# 8181 rather than 80/443 — the player's own DWS owns those, and taking them removes the way in if
# this goes wrong.
# Not 8080: it is the most contended unprivileged port there is, and a collision here presents as
# "the server did not start" with nothing pointing at the cause.
PORT=8181
# Local by design. The server is reachable on the LAN at the address the screen shows; nothing here
# dials out. Set these only if you actually want outbound mail.
# GRAPH_TENANT_ID=
# GRAPH_CLIENT_ID=
# GRAPH_CLIENT_SECRET=
# Off by default on a player: it is an outbound call from a device that is meant to be self-contained.
# TELEMETRY_COLLECTOR=
# The dependency preflight guards a NATIVE better-sqlite3. This build has none — it reaches SQLite
# through node:sqlite — so the check has nothing to verify and refuses to boot if left on.
ST_SKIP_DEP_PREFLIGHT=1

View file

@ -1,4 +0,0 @@
{
"_comment": "Copy to st-config.json on the player's storage root. Absent or 0 means this device does NOT run a server - which is the default, because exactly one box per site should host one.",
"server": 0
}

View file

@ -251,44 +251,6 @@
// legitimately supplied. Absent means "nothing to say", which is not the same as "zero". // legitimately supplied. Absent means "nothing to say", which is not the same as "zero".
var telemetry = {}; var telemetry = {};
// The attached panel's raw EDID, base64. Held apart from `telemetry` deliberately: it is
// IDENTITY, not a reading. It changes only when someone physically swaps the screen, so it rides
// the register (where hardware_model and hardware_serial already go) rather than the 15-second
// heartbeat, where ~350 characters of unchanging data would be pure noise forever.
var edidRaw = null;
/*
* Normalise whatever getEdid() hands back into base64.
*
* Its return type is undocumented and could not be determined from the firmware, so every
* plausible shape is handled rather than betting on one and shipping a silent null to the fleet.
* Returns null for anything unrecognisable the server treats a missing EDID as "unknown",
* which is honest, whereas a mangled one would be a lie that parses.
*/
function toBase64(v) {
try {
if (!v) return null;
if (typeof v === 'string') {
var s = v.trim();
if (!s) return null;
// Hex comes back roughly twice the length of the 128/256 bytes it encodes.
if (/^[0-9a-fA-F]+$/.test(s) && s.length >= 256) {
var by = [];
for (var i = 0; i < s.length; i += 2) by.push(parseInt(s.substr(i, 2), 16));
return toBase64(by);
}
return s; // already base64
}
var arr = (typeof v.length === 'number') ? v : (v.buffer ? new Uint8Array(v.buffer) : null);
if (!arr || !arr.length) return null;
var bin = '';
for (var j = 0; j < arr.length; j++) bin += String.fromCharCode(arr[j] & 0xff);
if (typeof global.btoa === 'function') return global.btoa(bin);
var B = tryRequire('buffer');
return B && B.Buffer ? B.Buffer.from(bin, 'binary').toString('base64') : null;
} catch (e) { return null; }
}
/* /*
* Facts pushed by the host, merged into the same cache the heartbeat reads. * Facts pushed by the host, merged into the same cache the heartbeat reads.
* *
@ -574,12 +536,6 @@
return null; return null;
}, },
// The attached panel's raw EDID as base64, or null until the async probe answers (and forever
// on a player whose firmware has no getEdid, or an output with nothing plugged in). The page
// sends it on register; the server stores it COALESCE-style, so a null never erases a value
// that arrived on an earlier connection.
edid: function () { return edidRaw; },
/* Which physical output this widget is painting. 1 unless autorun.brs made a second one. */ /* Which physical output this widget is painting. 1 unless autorun.brs made a second one. */
screen: screenNumber, screen: screenNumber,
@ -1026,34 +982,6 @@
if (typeof mn === 'string' && mn.trim()) telemetry.attached_display = mn.trim(); if (typeof mn === 'string' && mn.trim()) telemetry.attached_display = mn.trim();
}, function () { /* no display on this output */ }); }, function () { /* no display on this output */ });
} }
/*
* The RAW EDID, alongside the identity object above.
*
* getEdidIdentity() answers seven questions (monitorName, product, serialNumber, the
* manufacture date and the BT2020/HDR flags) and cannot answer any others. Everything
* else the player's own DWS prints manufacturer, EDID version, physical size, gamma,
* the VESA/standard/DTD mode lists, the CEA blocks is in these bytes.
*
* Sent as-is and parsed on the SERVER (server/lib/edid.js). Parsing here would mean a
* bridge update for every new field, and this file is the one behind a CDN that held it
* for four hours at a stretch. Bytes now, questions later.
*
* The return shape is not documented and was not observable from the firmware strings,
* so nothing is assumed: Uint8Array, Array, Buffer-like, hex or base64 all get
* normalised to base64 here, and the parser accepts every one of those anyway.
*/
if (typeof vo.getEdid === 'function') {
try {
var raw = vo.getEdid();
if (raw && typeof raw.then === 'function') {
raw.then(function (bytes) { edidRaw = toBase64(bytes) || edidRaw; },
function () { /* no EDID on this output */ });
} else if (raw) {
edidRaw = toBase64(raw) || edidRaw;
}
} catch (e) { /* older firmware without getEdid */ }
}
} catch (e) { /* no such output on this model */ } } catch (e) { /* no such output on this model */ }
} }
} }

View file

@ -1,103 +0,0 @@
# Licensing
ScreenTinker is MIT. This page records how we know what our dependencies are licensed under,
so the answer to "do you track licences?" is something you can check rather than something you
have to take on trust.
## The short answer
**No GPL or AGPL anywhere in the product.** Neither the server nor the Android player links,
bundles, or ships anything under strong or network copyleft.
## Where the answer comes from
Two gates run in CI on every push, and both fail closed — a dependency whose licence nobody has
recorded fails the build rather than shipping unnoticed.
| Gate | Covers | Script |
|---|---|---|
| Licence gate + SBOM (production deps) | the server's npm tree | `scripts/license-check.js` |
| Licence gate (APK runtime classpath) | everything that can enter the APK | `scripts/android-license-check.js` |
Run either locally:
```sh
cd server && npm ci --omit=dev && cd ..
node scripts/license-check.js # server
node scripts/android-license-check.js # APK
node scripts/license-check.js --sbom sbom/x.json # also write an SBOM
```
Neither script has dependencies of its own. A gate that needs its own supply chain audited is
worth less than one that doesn't.
## ⚠️ Audit the production install, not the checkout
**A licence scanner pointed at a developer checkout will report LGPL, and it will be wrong about
what we ship.**
`sharp` is a `devDependency` — a fixture generator for the image tests — and one of its platform
binaries, `@img/sharp-wasm32`, declares `Apache-2.0 AND LGPL-3.0-or-later AND MIT`. It is never
installed on a server: production installs with `npm ci --omit=dev`, which both the CI gate and
`scripts/upgrade.sh` use.
If someone challenges the answer with a scan of the repo, this is the discrepancy they have found.
`sharp` is kept deliberately: it is the *independent* implementation used to generate fixtures for
the pure-JavaScript image path that replaced it. Generating those fixtures with the library under
test would mean a decode bug could produce a fixture that hides the same bug.
## Policy
**Allowed** — MIT, MIT-0, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, Apache-2.0, BlueOak-1.0.0,
Unlicense, CC0-1.0, Python-2.0, WTFPL, Zlib, CC-BY-4.0.
**Denied** — AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence.
**Reported but not failed** — LGPL, MPL, EPL, CDDL, OSL, EUPL. Weak copyleft is file- or
library-scoped and usually fine when merely linked, but it is a judgement, and the judgement should
be made by someone who knows they are making it.
**Unrecognised — fails.** A package with no licence we can identify is not a package we ship. Where
a dependency ships a real licence *file* but declares no `license` field, it is recorded as an
exception in the script with the evidence that was read off disk (currently `exif-parser` and
`thirty-two`, both MIT).
### Why the JSON Licence is denied
`org.json:json:20090211` arrived transitively through `socket.io-client` and was **packaged into the
APK in full** — 19 classes, including ones nothing referenced. Its licence carries the clause *"The
Software shall be used for Good, not Evil"*: not OSI-approved, treated as non-free by Debian and
Fedora, and Category X at Apache. Not copyleft, but not a term to accept in a binary distributed
commercially.
It is now excluded in `android/app/build.gradle.kts`. Nothing is lost — Android has provided
`org.json` in the platform since API 1 and `minSdk` is 24 — and `android/licenses.json` denies it by
name so it cannot return quietly.
## SBOM
Every release publishes `screentinker-sbom-<version>.cdx.json`: **CycloneDX 1.5**, listing every
production dependency with its version, package URL, and licence. Generated from a production
install, so it describes what actually runs.
CI also uploads one as a build artifact on every run.
## Vendored code
Anything committed under `frontend/vendor/` **ships in the release tarball** and must carry its
licence notice as a separate file — minifiers strip headers, which is exactly when the notice has to
be kept alongside. See `frontend/vendor/README.md`.
## The GLSL transitions
The 14 shaders in `shared/Transitions/` are original work. Each carries its author and licence in
the file header, and none derives from Shadertoy, gl-transitions, glslsandbox or similar. "GL
Transitions v1" in those headers refers to the *interface convention* — the function signature the
renderer calls — not to borrowed code.
## Limits
These gates identify licences from declared metadata and recorded evidence. They are not a
clean-room provenance review, and they do not detect code copied into the repository without
attribution.

View file

@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: ScreenTinker Public API title: ScreenTinker Public API
version: 1.9.36 version: 1.9.35
description: | description: |
Public, token-scoped REST API for ScreenTinker digital signage. Public, token-scoped REST API for ScreenTinker digital signage.

View file

@ -13,21 +13,10 @@ given) · 💀 **dead**: the capability is declared or baselined but the control
BrightSign runs the *same* `server/player/index.html` as the browser, so it differs only where the BrightSign runs the *same* `server/player/index.html` as the browser, so it differs only where the
`autorun.brs` host bridge adds something the browser cannot reach. The bridge has two halves: the `autorun.brs` host bridge adds something the browser cannot reach. The bridge has two halves: the
JS (`brightsign/st-bridge.js`, served by us at `/player/st-bridge.js` — always current, but see the JS (`brightsign/st-bridge.js`, served by us at `/player/st-bridge.js`, always current) and the
CDN note below) and the
on-device BrightScript that must create the widget with `nodejs_enabled:true`. `BS.hasHost()` is on-device BrightScript that must create the widget with `nodejs_enabled:true`. `BS.hasHost()` is
false unless BOTH are present, and everything host-backed hangs off it. false unless BOTH are present, and everything host-backed hangs off it.
> **CDN note.** "Always current" was false in the hosted deployment for months. The origin sets
> `Cache-Control: no-cache` on `/player/*` deliberately (`server/server.js`), but Cloudflare's
> zone-wide **Browser Cache TTL** (14400s) rewrote it to `max-age=14400` for static extensions, so
> every player held a 4-hour-stale bridge *on device* — and purging the edge did not help, because
> the TTL was a browser directive. A new-page-against-old-bridge skew is exactly what makes
> `BS.<method>()` throw and a display self-report as crashed. Fixed 2026-08-14 by a cache rule on
> `(http.request.uri.path eq "/player") or starts_with(http.request.uri.path, "/player/")` setting
> `browser_ttl: respect_origin` — edge caching is retained (it revalidates), only the browser
> directive is corrected. If this behaviour ever returns, check that rule before debugging code.
Verified at `2237eda`. Where a row cites "the fielded build" it means `v1.9.28` — the last release Verified at `2237eda`. Where a row cites "the fielded build" it means `v1.9.28` — the last release
before any player declared anything, and therefore the build every baseline is describing. before any player declared anything, and therefore the build every baseline is describing.
@ -322,7 +311,7 @@ declares `system.reboot` for itself and is unaffected.
| `display.rotation` | ✅ | ✅ | ⚠️ graphics only | ✅ | | `display.rotation` | ✅ | ✅ | ⚠️ graphics only | ✅ |
| `display.power` | ❌ `screen_on` is a no-op | ✅ | ❌ needs host | ❌ | | `display.power` | ❌ `screen_on` is a no-op | ✅ | ❌ needs host | ❌ |
| `display.brightness` | ✅ Tier 0, since v1.9.10 | ❌ | ❌ | ❌ | | `display.brightness` | ✅ Tier 0, since v1.9.10 | ❌ | ❌ | ❌ |
| `remote.screenshot` / `remote.stream` | ✅ view capture | ✅ images only | ✅ native capture, see note | ✅ | | `remote.screenshot` / `remote.stream` | ✅ view capture | ✅ images only | ❌ no video plane | ✅ |
| `remote.input` | ✅ | ✅ | ✅ | ✅ | | `remote.input` | ✅ | ✅ | ✅ | ✅ |
| `system.restart_player` | ✅ | ✅ | ❌ widget may not return | ✅ | | `system.restart_player` | ✅ | ✅ | ❌ widget may not return | ✅ |
| `system.self_update` | ✅ | ❌ | ❌ needs host | ❌ | | `system.self_update` | ✅ | ❌ | ❌ needs host | ❌ |
@ -330,19 +319,6 @@ declares `system.reboot` for itself and is unaffected.
| `sync.clock` | ✅ | ✅ | ✅ | ✅ | | `sync.clock` | ✅ | ✅ | ✅ | ✅ |
| `offline.cache` | ✅ | ❌ playlist JSON only | ❌ ❓ unverified | ✅ | | `offline.cache` | ✅ | ❌ playlist JSON only | ❌ ❓ unverified | ✅ |
**Note on BrightSign capture.** The table used to read "❌ no video plane", on the reasoning that a
DOM canvas cannot read the hardware plane a hwz player puts video on. That is still true of the
canvas, but it is no longer the path taken: `st-bridge.js` `captureScreen()` uses the native
`@brightsign/screenshot` module, which composites both planes. Confirmed on hardware (XT245,
BrightSignOS 10.0.16); the module's API is unchanged between OS 9 and 10 — `syncCapture` /
`asyncCapture` with `destinationFileName`, and `fileName` still honoured as an alias. It needs
`require()` (i.e. `nodejs_enabled:true`) but **not** `BS.hasHost()`, so it works on widgets with no
messageport. On a widget with no node integration at all — one built by the BSN Supervisor — the
path really does fall back to canvas, and there the player now paints "Video is playing on the
hardware plane and cannot be captured" instead of a silent black frame. The **baseline** still
withholds both because it cannot tell those two widget kinds apart; every player that runs our page
declares them for itself regardless.
Everything conditional at runtime on every platform that has it at all — `system.kiosk`, Everything conditional at runtime on every platform that has it at all — `system.kiosk`,
`system.brightness`, `system.screen_timeout`, `system.install_apk`, `system.shell`, `system.time`, `system.brightness`, `system.screen_timeout`, `system.install_apk`, `system.shell`, `system.time`,
`system.device_owner`, `sync.native`, `display.resolution` — is absent from **every** baseline, and `system.device_owner`, `sync.native`, `display.resolution` — is absent from **every** baseline, and

View file

@ -8,10 +8,9 @@ import de from './i18n/de.js';
import pt from './i18n/pt.js'; import pt from './i18n/pt.js';
import hi from './i18n/hi.js'; import hi from './i18n/hi.js';
import it from './i18n/it.js'; import it from './i18n/it.js';
import ja from './i18n/ja.js';
const fallback = en; const fallback = en;
const registry = { en, es, fr, de, pt, hi, it, ja }; const registry = { en, es, fr, de, pt, hi, it };
let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en'; let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en';
if (!registry[currentLang]) currentLang = 'en'; if (!registry[currentLang]) currentLang = 'en';
@ -66,7 +65,6 @@ export function getLanguage() {
export function getAvailableLanguages() { export function getAvailableLanguages() {
return [ return [
{ code: 'en', name: 'English' }, { code: 'en', name: 'English' },
{ code: 'ja', name: '日本語' },
{ code: 'es', name: 'Español' }, { code: 'es', name: 'Español' },
{ code: 'fr', name: 'Français' }, { code: 'fr', name: 'Français' },
{ code: 'it', name: 'Italiano' }, { code: 'it', name: 'Italiano' },

View file

@ -1204,5 +1204,6 @@ export default {
'add_display.web_player': 'Web-Player', 'add_display.web_player': 'Web-Player',
'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows', 'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TVs (LG/Samsung): öffnen Sie den integrierten Browser und navigieren Sie zu <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Bildschirm koppeln', 'add_display.pair_btn': 'Bildschirm koppeln',
}; };

View file

@ -577,12 +577,6 @@ export default {
'device.info.os_version': 'OS Version', 'device.info.os_version': 'OS Version',
'device.info.serial': 'Serial', 'device.info.serial': 'Serial',
'device.info.temperature': 'Temperature', 'device.info.temperature': 'Temperature',
'device.info.edid': 'Display EDID',
'device.info.edid_preferred': 'Preferred mode',
'device.info.edid_made': 'Manufactured',
'device.info.edid_serial': 'Serial',
'device.info.edid_product': 'Product',
'device.info.edid_checksum_bad': 'EDID checksum invalid — the panel or cable may be faulty',
'device.info.attached_display': 'Attached display', 'device.info.attached_display': 'Attached display',
'device.info.video_mode': 'Video mode', 'device.info.video_mode': 'Video mode',
'device.info.output_n': '(output {n})', 'device.info.output_n': '(output {n})',
@ -1591,7 +1585,6 @@ export default {
'schedule.hour_12pm': '12pm', 'schedule.hour_12pm': '12pm',
'schedule.hour_pm': 'pm', 'schedule.hour_pm': 'pm',
'schedule.toast.no_groups': 'No groups available. Create a group first.', 'schedule.toast.no_groups': 'No groups available. Create a group first.',
'schedule.toast.target_required': 'Select a screen or group before saving.',
'schedule.toast.saved': 'Schedule saved', 'schedule.toast.saved': 'Schedule saved',
// Reports // Reports

View file

@ -1204,5 +1204,6 @@ export default {
'add_display.web_player': 'Lecteur web', 'add_display.web_player': 'Lecteur web',
'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows', 'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TVs (LG/Samsung) : ouvrez le navigateur intégré et allez à <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Apparier l\'écran', 'add_display.pair_btn': 'Apparier l\'écran',
}; };

View file

@ -1162,5 +1162,6 @@ export default {
'add_display.web_player': 'Web Player', 'add_display.web_player': 'Web Player',
'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows', 'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TV (LG/Samsung): apri il browser integrato e vai su <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Associa Schermo', 'add_display.pair_btn': 'Associa Schermo',
}; };

File diff suppressed because it is too large Load diff

View file

@ -1204,5 +1204,6 @@ export default {
'add_display.web_player': 'Player web', 'add_display.web_player': 'Player web',
'add_display.raspberry_pi': 'Raspberry Pi', 'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows', 'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TVs (LG/Samsung): abra o navegador integrado e vá para <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Parear tela', 'add_display.pair_btn': 'Parear tela',
}; };

View file

@ -1,39 +0,0 @@
/*
* What the login form shows, as a pure decision.
*
* Extracted because it got this wrong in a way nobody could see from reading the handlers: the
* state lived in two mutable flags updated from four event listeners, and one of those listeners
* undid first-run setup on the first keystroke.
*
* THE BUG. On a fresh install with no users, the page set identified = true so both fields were
* available, because there is nobody to identify - the operator is creating the first account.
* Then typing in the email box fired the "editing the address returns to the identifier step"
* listener, which set identified = false and re-applied the state, hiding the password field
* mid-typing and relabelling the button "Next".
*
* Identifier-first exists to ask the server which identity provider an EXISTING address uses, so
* an SSO-only user is never shown a password box that will be refused. With an empty user table
* there is no such question, so the whole two-step flow has to be inert - not merely initialised
* to a state that a later event can undo.
*/
/**
* @param {{isSetup: boolean, identified: boolean, ssoOnlyDomain: boolean}} state
* @returns {{showPassword: boolean, showButton: boolean, buttonKey: string}}
*/
export function loginFormState({ isSetup, identified, ssoOnlyDomain }) {
// First-run setup: every field is needed at once and no event may take one away. Deliberately
// ignores `identified` and `ssoOnlyDomain` rather than trusting them to hold the right values -
// that trust is what broke.
if (isSetup) {
return { showPassword: true, showButton: true, buttonKey: 'auth.create_admin_account' };
}
const known = identified && !ssoOnlyDomain;
return {
showPassword: known,
// An SSO-only domain has nothing to press: the provider button is the only way in.
showButton: !ssoOnlyDomain,
buttonKey: known ? 'auth.sign_in' : 'auth.next',
};
}

View file

@ -618,23 +618,6 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.video_mode')}</div> <div class="info-card-label">${t('device.info.video_mode')}</div>
<div class="info-card-value small" id="telVideoMode">${esc(latestTelemetry.video_mode)}</div> <div class="info-card-value small" id="telVideoMode">${esc(latestTelemetry.video_mode)}</div>
</div>` : ''} </div>` : ''}
<!-- The panel's own EDID, parsed server-side from the raw block the player reported.
Answers the questions the player's DWS answers and the dashboard previously could
not: which panel is this, how old is it, what does it actually want to be driven at.
Absent entirely on a device that never reported one, rather than an empty card. -->
${device.edid ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.edid')}</div>
<div class="info-card-value small">${esc(device.edid.manufacturer || '')} ${esc(device.edid.monitorName || device.edid.productHex || '')}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;line-height:1.5">
${device.edid.preferredMode ? `${t('device.info.edid_preferred')}: <strong>${esc(device.edid.preferredMode)}</strong><br>` : ''}
${device.edid.widthCm ? `${esc(device.edid.widthCm)}&times;${esc(device.edid.heightCm)} cm &middot; ` : ''}${device.edid.digital ? 'digital' : 'analog'} &middot; EDID ${esc(device.edid.edidVersion)}<br>
${device.edid.yearOfManufacture ? `${t('device.info.edid_made')}: ${esc(device.edid.yearOfManufacture)}w${String(device.edid.weekOfManufacture).padStart(2, '0')}<br>` : ''}
${device.edid.serialNumber ? `${t('device.info.edid_serial')}: ${esc(device.edid.serialNumber)} &middot; ` : ''}${t('device.info.edid_product')}: ${esc(device.edid.productHex)}
${device.edid.cea && (device.edid.cea.bt2020Rgb || device.edid.cea.bt2020Ycc) ? `<br>BT.2020${device.edid.cea.hdrSt2084 ? ' &middot; HDR10' : ''}` : ''}
${device.edid.checksumValid === false ? `<br><span style="color:var(--warning,#f59e0b)">${t('device.info.edid_checksum_bad')}</span>` : ''}
</div>
</div>` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">${t('device.info.wifi')}</div> <div class="info-card-label">${t('device.info.wifi')}</div>
@ -646,24 +629,15 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.uptime')}</div> <div class="info-card-label">${t('device.info.uptime')}</div>
<div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div> <div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div>
</div> </div>
<!-- Player version, on EVERY platform. This card used to sit inside the Android-only
block below, so a BrightSign or Tizen panel never showed a version at all: they
register android_version as "Web/<ua>", which fails that test. On a BrightSign
app_version is the ON-DEVICE host package (autorun.brs, what OTA replaces) and
client_version is the page we serve, which is always current so where the two
differ, both are worth showing: the pair is what tells you whether a panel is
running a stale host against a fresh page. -->
<div class="info-card">
<div class="info-card-label">${t('device.info.app_version')}</div>
<div class="info-card-value small">${esc(device.app_version || '--')}</div>
${device.client_version && device.client_version !== device.app_version ? `
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${esc(device.client_version)}</div>` : ''}
</div>
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">${t('device.info.android_version')}</div> <div class="info-card-label">${t('device.info.android_version')}</div>
<div class="info-card-value small">${device.android_version}</div> <div class="info-card-value small">${device.android_version}</div>
</div> </div>
<div class="info-card">
<div class="info-card-label">${t('device.info.app_version')}</div>
<div class="info-card-value small">${device.app_version || '--'}</div>
</div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">${t('device.info.settings_pin')}</div> <div class="info-card-label">${t('device.info.settings_pin')}</div>
<div class="info-card-value small" style="font-family:monospace;letter-spacing:1px">${device.settings_pin || '--'}</div> <div class="info-card-value small" style="font-family:monospace;letter-spacing:1px">${device.settings_pin || '--'}</div>
@ -864,30 +838,11 @@ async function loadDevice(deviceId, activeTab = null) {
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">${t('device.remote.vol_down')}</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">${t('device.remote.vol_down')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<!-- System View controls auto-unlocked on a device owner (#161: full-screen via the <!-- System View controls auto-unlocked on a device owner (#161: full-screen via the
accessibility path, no MediaProjection consent); locked until enabled otherwise. accessibility path, no MediaProjection consent); locked until enabled otherwise. -->
<div id="systemViewControls" style="opacity:${device.tier === 2 ? '1' : '0.4'};pointer-events:${device.tier === 2 ? 'auto' : 'none'}">
LOCKED ONLY ON ANDROID. tier is an Android device-owner concept: NOT NULL
DEFAULT 0 (db/database.js), written only from the APK's DeviceInfo. A BrightSign,
Tizen or web player never sends it, so it is structurally 0 for them and can never
reach 2 which left this pad permanently click-blocked (pointer-events:none) on
those platforms for keys the player genuinely HANDLES: HOME, BACK, POWER, D-pad and
OK all have cases in server/player/index.html and tizen/js/app.js. Greying an
Android-only gate over a working control is the "button that cannot work" this
capability system exists to prevent, just inverted. Off Android there is no tier to
earn and nothing to unlock.
Expression kept INLINE rather than hoisted to a const, because
server/test/device-controls-hidden.test.js renders this template in a bare VM
sandbox that supplies device and isAndroidDevice but no locals from render().
NOTE: no backticks anywhere in this comment - it lives inside a template literal
and one would terminate the string. -->
<div id="systemViewControls" style="opacity:${isAndroidDevice(device) && device.tier !== 2 ? '0.4' : '1'};pointer-events:${isAndroidDevice(device) && device.tier !== 2 ? 'none' : 'auto'}">
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_HOME')">${t('device.remote.home')}</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_HOME')">${t('device.remote.home')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_BACK')">${t('device.remote.back')}</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_BACK')">${t('device.remote.back')}</button>
${isAndroidDevice(device) ? ` <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_APP_SWITCH')">${t('device.remote.recents')}</button>
<!-- KEYCODE_APP_SWITCH is handled only by the APK (WebSocketService.kt). The web
player and Tizen have no case for it, so off Android it is a dead button. -->
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_APP_SWITCH')">${t('device.remote.recents')}</button>` : ''}
<button class="btn btn-danger btn-sm" onclick="window._sendKey('KEYCODE_POWER')">${t('device.remote.power')}</button> <button class="btn btn-danger btn-sm" onclick="window._sendKey('KEYCODE_POWER')">${t('device.remote.power')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_UP')">&#9650;</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_UP')">&#9650;</button>
@ -897,12 +852,8 @@ async function loadDevice(deviceId, activeTab = null) {
</div> </div>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_DOWN')">&#9660;</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_DOWN')">&#9660;</button>
<button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">${t('device.remote.ok')}</button> <button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">${t('device.remote.ok')}</button>
${isAndroidDevice(device) ? `
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<!-- 'settings' opens the Android settings activity. It is not in COMMAND_CAPABILITY, <button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">${t('device.remote.settings')}</button>
so the server forwards it to any player and a non-Android one silently drops it
(tizen/js/app.js falls through to "unknown command"). -->
<button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">${t('device.remote.settings')}</button>` : ''}
${can('display.power') ? ` ${can('display.power') ? `
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<div style="display:flex;gap:4px"> <div style="display:flex;gap:4px">

View file

@ -1,5 +1,4 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { loginFormState } from '../lib/login-form-state.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
@ -329,9 +328,6 @@ function setupHandlers(config, isSetup) {
* their domain must get a fresh answer rather than keep the previous domain's one. * their domain must get a fresh answer rather than keep the previous domain's one.
*/ */
document.getElementById('loginEmail')?.addEventListener('input', () => { document.getElementById('loginEmail')?.addEventListener('input', () => {
// Nothing to step back to during first-run setup - there is no identifier step. This used to
// fire on the first keystroke and hide the password field the operator was about to fill in.
if (isSetup) return;
if (!identified) return; if (!identified) return;
identified = false; identified = false;
applyFormState(); applyFormState();
@ -582,10 +578,8 @@ function setupHandlers(config, isSetup) {
let ssoOnlyDomain = false; let ssoOnlyDomain = false;
function applyFormState() { function applyFormState() {
// One decision, in one place, from ../lib/login-form-state.js. It used to be computed inline const showPassword = identified && !ssoOnlyDomain;
// from two mutable flags, which is how first-run setup ended up being undone by a keystroke. const show = showPassword ? '' : 'none';
const state = loginFormState({ isSetup, identified, ssoOnlyDomain });
const show = state.showPassword ? '' : 'none';
/* /*
* Hide the password FIELD, never its .form-group the organization SSO slot lives inside * Hide the password FIELD, never its .form-group the organization SSO slot lives inside
* that same group, so hiding the container took the single sign-on button down with it. * that same group, so hiding the container took the single sign-on button down with it.
@ -600,8 +594,8 @@ function setupHandlers(config, isSetup) {
* rather than two, so there is never a choice about which to press. * rather than two, so there is never a choice about which to press.
*/ */
const btn = document.getElementById('loginBtn'); const btn = document.getElementById('loginBtn');
if (btn) btn.textContent = t(state.buttonKey); if (btn) btn.textContent = identified && !ssoOnlyDomain ? t('auth.sign_in') : t('auth.next');
if (btn) btn.style.display = state.showButton ? '' : 'none'; if (btn) btn.style.display = ssoOnlyDomain ? 'none' : '';
/* /*
* The instance's own providers stay visible at ALL times, by explicit decision: they are the * The instance's own providers stay visible at ALL times, by explicit decision: they are the

View file

@ -210,17 +210,6 @@ export async function render(container) {
`${currentWeekStart.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`; `${currentWeekStart.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
} }
// The date the modal is currently working on: a dragged day, the date of the schedule
// being edited, or null meaning "today". Set by every path that OPENS the modal.
let pendingCreateDate = null;
// The selected calendar week is a local calendar concept, not an instant. Sending midnight
// through toISOString() turns Sunday in a timezone east of UTC into Saturday for a US server,
// which makes that server return the prior week. Keep the local Y-M-D intact instead.
function calendarDateParam(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
// Stable colour per target, so the same screen is the same colour every week and // Stable colour per target, so the same screen is the same colour every week and
// across reloads. Hashing the id beats cycling a palette by index, which reshuffles // across reloads. Hashing the id beats cycling a palette by index, which reshuffles
// whenever a device is added or removed. // whenever a device is added or removed.
@ -245,7 +234,7 @@ export async function render(container) {
// all=1 rather than a workspace id — the server scopes to the caller's own workspace. // all=1 rather than a workspace id — the server scopes to the caller's own workspace.
const scope = allScreens ? 'all=1' : `device_id=${encodeURIComponent(deviceId)}`; const scope = allScreens ? 'all=1' : `device_id=${encodeURIComponent(deviceId)}`;
const events = await API(`/schedules/week?date=${calendarDateParam(currentWeekStart)}&${scope}`); const events = await API(`/schedules/week?date=${currentWeekStart.toISOString()}&${scope}`);
const cal = document.getElementById('calendar'); const cal = document.getElementById('calendar');
// Narrow screens render ONE day. Seven columns in a phone-width window leaves ~50px each — // Narrow screens render ONE day. Seven columns in a phone-width window leaves ~50px each —
@ -683,20 +672,10 @@ export async function render(container) {
if (endEl) endEl.value = hhmm(endMin); if (endEl) endEl.value = hhmm(endMin);
pendingCreateDate = dayDate; pendingCreateDate = dayDate;
} }
let pendingCreateDate = null;
function editSchedule(ev) { function editSchedule(ev) {
editingId = ev.id; editingId = ev.id;
// ⚠️ THE DATE OF THE SCHEDULE BEING EDITED.
//
// Save rebuilds start_time from this date plus the HH:MM in the form. Without it, dref fell
// through to `new Date()` and EVERY edit silently moved the schedule to today - change a
// colour on a block dated 5 Aug and it jumped to this week. Nothing about that is timezone
// dependent; it hit every operator.
//
// ev.start_time is the SCHEDULE's own anchor (raw from the row), not the occurrence that was
// clicked, so editing a recurring rule keeps its start date rather than re-anchoring it to
// whichever instance the operator happened to click.
pendingCreateDate = new Date(ev.start_time);
document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule'); document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule');
document.getElementById('schedPlaylist').value = ev.playlist_id || ''; document.getElementById('schedPlaylist').value = ev.playlist_id || '';
document.getElementById('schedLayout').value = ev.layout_id || ''; document.getElementById('schedLayout').value = ev.layout_id || '';
@ -725,12 +704,6 @@ export async function render(container) {
document.getElementById('addScheduleBtn').onclick = () => { document.getElementById('addScheduleBtn').onclick = () => {
editingId = null; editingId = null;
// Reset the date on OPEN, not on close. The modal has two inline onclick="...display='none'"
// dismissers (the X and Cancel) that cannot touch this scope, so a date left over from a
// drag-create survived a cancel and stamped itself on the NEXT schedule created. Setting it
// on every open makes the stale value unreachable no matter how the modal was dismissed.
// The drag-create path calls this handler first and then assigns its own day, so it still wins.
pendingCreateDate = null;
document.getElementById('schedModalTitle').textContent = t('schedule.add_schedule'); document.getElementById('schedModalTitle').textContent = t('schedule.add_schedule');
document.getElementById('schedTitle').value = ''; document.getElementById('schedTitle').value = '';
document.getElementById('schedPlaylist').value = ''; document.getElementById('schedPlaylist').value = '';
@ -767,12 +740,6 @@ export async function render(container) {
return; return;
} }
const targetId = isGroup ? groupSelect.value : deviceSelect.value;
if (!targetId) {
showToast(t('schedule.toast.target_required'), 'error');
return;
}
const playlistId = document.getElementById('schedPlaylist').value; const playlistId = document.getElementById('schedPlaylist').value;
const layoutId = document.getElementById('schedLayout').value; const layoutId = document.getElementById('schedLayout').value;
@ -795,9 +762,9 @@ export async function render(container) {
}; };
if (isGroup) { if (isGroup) {
data.group_id = targetId; data.group_id = groupSelect.value;
} else { } else {
data.device_id = targetId; data.device_id = deviceSelect.value;
} }
try { try {

View file

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

View file

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

View file

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

View file

@ -1,67 +0,0 @@
#!/bin/bash
# Build brightsign/autorun-boot.zip — ONLY the four files needed to start, no server payload.
#
# scripts/build-server-boot-zip.sh [-o path]
#
# WHY THIS EXISTS. The full server package is ~73MB across 9,356 entries, and BrightSignOS cannot
# open it: the boot-time autorun scan reports
#
# Failed to use zipped 'SSD:/autorun.zip': ZipArchive error at line 91
#
# and falls through to "Load or runtime error in autorun. Forcing recovery." Provisioning CAN unpack
# the same archive — the files land on disk — so the limit is specifically in the OS's own zip
# reader, not in the archive. Path lengths (max 182 chars) and depth (8) are well inside anything
# reasonable, which leaves size and entry count.
#
# So the OS gets an archive shaped exactly like the player package that already works on this
# hardware: a handful of small files, STORED, at the root. The ~71MB of server + node_modules is
# delivered separately and unpacked by Node, which has no such limit.
#
# This build is deliberately ALSO the isolation test: if the player boots this and shows
# "server payload not installed" on screen, the size hypothesis is confirmed and the two-stage
# design is right. If it still fails to open THIS, the problem is something else entirely and no
# amount of splitting would have helped.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT="brightsign/autorun-boot.zip"
while [ $# -gt 0 ]; do
case "$1" in
-o|--out) OUT="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
command -v zip >/dev/null || { echo "ERROR: 'zip' is not installed." >&2; exit 1; }
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp brightsign/autozip.brs "$STAGE/autozip.brs"
cp brightsign/server/autorun.brs "$STAGE/autorun.brs"
cp brightsign/server/bs-server-boot.js "$STAGE/bs-server-boot.js"
cp brightsign/server/bs-payload-install.js "$STAGE/bs-payload-install.js"
cp brightsign/server/node-server.html "$STAGE/node-server.html"
cp brightsign/server/server.env.example "$STAGE/server.env.example"
# Shipped as .example ONLY. A file named st-config.json would be extracted over the operator's own
# on every re-provision, silently switching a site's server on or off.
cp brightsign/server/st-config.example.json "$STAGE/st-config.example.json"
mkdir -p "$(dirname "$OUT")"
rm -f "$OUT"
ABS_OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
# STORED, for the same reason as every other package here: roBrightPackage documents "no
# compression" as the universally safe option, and a deflated archive deploys perfectly then fails
# to open on the player.
( cd "$STAGE" && zip -q -r -X -0 "$ABS_OUT" . )
echo " built $OUT ($(du -h "$ABS_OUT" | cut -f1))"
unzip -l "$OUT" | sed 's/^/ /'
LISTING="$(unzip -l "$OUT")"
for required in autorun.brs autozip.brs bs-server-boot.js bs-payload-install.js node-server.html; do
case "$LISTING" in *" $required"*) ;; *) echo "ERROR: $required missing" >&2; exit 1 ;; esac
done
COMPRESSED="$(unzip -v "$OUT" | awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[A-Za-z]/ && $2 != "Stored" {print $2}' | head -1)"
[ -n "$COMPRESSED" ] && { echo "ERROR: compressed members present" >&2; exit 1; }
echo " root-level layout verified, all members stored"

View file

@ -1,350 +0,0 @@
#!/bin/bash
# Build brightsign/autorun-server.zip — the ScreenTinker SERVER, packaged to run ON a player.
#
# scripts/build-server-zip.sh [-o path/to/autorun-server.zip]
#
# Drop it on the storage root of a BrightSign running OS 10 (Node 24) and power-cycle. autozip.brs
# unpacks it in place and autorun.brs launches the server with roNodeJs, painting a diagnostic
# screen: URL, uptime, memory, disk, database size and a tail of the server's own console.
#
# ⚠️ THE BUNDLE MUST CONTAIN NO NATIVE CODE. That is the whole reason this is buildable on an
# x86_64 laptop for an aarch64 player. The server reaches SQLite through node:sqlite — built into
# the Node that BrightSignOS 10 already ships — via server/db/sqlite-compat.js. better-sqlite3 was
# the last native dependency; with it gone there is nothing to cross-compile, no ABI to match and
# no node-gyp to fail on a device with three slow cores. This script VERIFIES that rather than
# trusting it: any .node binary in the staged tree is a hard error, because the failure it would
# otherwise cause happens on the player, at boot, with no console.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=""
PAYLOAD_ONLY=0
while [ $# -gt 0 ]; do
case "$1" in
# Build the PAYLOAD half: everything except the boot files, for BrightSignOS 10 where a large
# autorun.zip cannot be opened by the boot-time zip reader. scripts/build-server-boot-zip.sh
# builds the other half, and bs-payload-install.js fetches this one onto the device.
--payload) PAYLOAD_ONLY=1; shift ;;
-o|--out) OUT="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
[ -n "$OUT" ] || { [ "$PAYLOAD_ONLY" = 1 ] && OUT="brightsign/server-payload.zip" || OUT="brightsign/autorun-server.zip"; }
command -v zip >/dev/null || { echo "ERROR: 'zip' is not installed." >&2; exit 1; }
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# ⚠️ UNTRACKED FILES DO NOT SHIP, AND THE FAILURE LANDS ON THE PLAYER.
#
# Staging is `git ls-files`, which is the right call - it keeps databases, uploads and .env out by
# construction. The cost is that a NEW file that has not been `git add`ed is silently omitted while
# every file that requires it ships happily. That is not hypothetical twice over: it happened to
# db/sqlite-compat.js, and then to lib/fsutil.js, which reached a player as
#
# Cannot find module '../lib/fsutil'
# Require stack: /storage/ssd/server/db/database.js
#
# after a 73MB download and a two-minute extraction. The specific guard below for sqlite-compat.js
# was written after the first one; this is the general form, so there is no third.
UNTRACKED="$(git ls-files --others --exclude-standard -- server frontend scripts docs shared brightsign \
| grep -E '\.(js|json|html|brs|css|sql)$' || true)"
if [ -n "$UNTRACKED" ]; then
echo "ERROR: these source files are not tracked by git and would NOT be packaged:" >&2
echo "$UNTRACKED" | sed 's/^/ /' >&2
echo " git add them (or remove them) before building." >&2
exit 1
fi
echo " staging server..."
mkdir -p "$STAGE/server"
# ⚠️ GIT DECIDES WHAT SHIPS. Not a hand-written exclude list.
#
# The first version of this used --exclude and it staged 291MB: server/db/remote_display.db (a real
# 33MB database), server/uploads (105MB of customer content), server/certs, and .env. Every one of
# those is already in .gitignore — the knowledge existed, the packager just was not using it. A
# manual list is also the wrong shape: it has to be updated every time someone adds a secret, and
# the failure mode is silent and shipped.
#
# `git ls-files` yields exactly the tracked source, so anything gitignored is excluded by
# construction and stays excluded as the repo grows. node_modules is installed fresh below rather
# than copied, so a developer's x86_64 better-sqlite3 cannot ride along either.
#
# THE SERVER IS NOT SELF-CONTAINED, so the package reproduces the repo layout rather than just
# server/. Discovered the hard way: it resolves ../frontend for the dashboard (config.js:34),
# ../scripts for the multi-tenancy migration, ../VERSION, ../brightsign to build the PLAYER package
# it serves at /api/brightsign/package, and ../docs. Ship server/ alone and it boots, migrates, and
# then dies on `Cannot find module '../../scripts/migrate-multitenancy'`.
#
# Deliberately NOT shipped: android/ (228MB of APK build), video/, Examples/, audit/, tizen/ —
# none of which the server reads at runtime.
for p in server frontend scripts docs shared brightsign VERSION package.json; do
git ls-files -z -- "$p" \
| grep -zZv -E '^server/(test|node_modules)/' \
| while IFS= read -r -d '' f; do
mkdir -p "$STAGE/$(dirname "$f")"
cp "$f" "$STAGE/$f"
done
done
if [ "$PAYLOAD_ONLY" = 0 ]; then
cp brightsign/server/autorun.brs "$STAGE/autorun.brs"
cp brightsign/server/bs-server-boot.js "$STAGE/bs-server-boot.js"
cp brightsign/server/server.env.example "$STAGE/server.env.example"
cp brightsign/server/node-server.html "$STAGE/node-server.html"
cp brightsign/autozip.brs "$STAGE/autozip.brs"
fi
# Point the server at the built-in driver. The shim is API-compatible, so no call site changes —
# this rewires the two places that construct a Database and drops the dependency entirely.
echo " switching to node:sqlite..."
python3 - "$STAGE" <<'PY'
import json, os, sys
stage = sys.argv[1]
root = os.path.join(stage, 'server')
# The rewire is worthless without the shim itself. It is a TRACKED source file, so if it is missing
# the staging step never saw it — which happened once because it had been written but not `git
# add`ed, and the only symptom was the server refusing to start with "Cannot find module
# './sqlite-compat.js'".
shim = os.path.join(root, 'db', 'sqlite-compat.js')
if not os.path.exists(shim):
sys.exit("ERROR: server/db/sqlite-compat.js is not in the staged tree — is it committed to git?")
# NOTHING IS REWRITTEN. The shim is installed UNDER THE NAME better-sqlite3 after npm runs (see
# below), so every require resolves to it by construction.
#
# Rewriting the requires textually was the obvious approach and it does not work: scripts/ reaches
# for the module three DYNAMIC ways —
# require(resolveFromServer('better-sqlite3'))
# require(require.resolve('better-sqlite3', { paths: [SERVER_DIR] }))
# require(path.join(__dirname, '..', 'server', 'node_modules', 'better-sqlite3'))
# — none of which a string sweep can see. The first is in migrate-multitenancy.js, which runs during
# first boot, so the miss surfaced as "Migration FAILED" with a half-migrated database long after
# the server appeared to start cleanly.
pkg = os.path.join(root, 'package.json')
d = json.load(open(pkg))
if 'better-sqlite3' in d.get('dependencies', {}):
del d['dependencies']['better-sqlite3']
print(" dropped better-sqlite3 from dependencies")
# The player has Node 24; say so, so an accidental install on anything older fails loudly.
d['engines'] = {'node': '>=24.0.0'}
json.dump(d, open(pkg, 'w'), indent=2)
PY
echo " installing production dependencies..."
( cd "$STAGE/server" && rm -f package-lock.json && npm install --omit=dev --no-audit --no-fund --silent )
# THE INVARIANT. A single .node file here means the package cannot run on the player, and the way
# you would find out is a boot loop on a box with no console.
# Install the façade under the real package's name. Done AFTER npm so nothing can overwrite it.
echo " installing the node:sqlite shim as better-sqlite3..."
mkdir -p "$STAGE/server/node_modules/better-sqlite3"
printf '%s\n' \
'{' \
' "name": "better-sqlite3",' \
' "version": "0.0.0-node-sqlite-shim",' \
' "description": "Not the real better-sqlite3 - a facade over node:sqlite so this bundle carries no native code.",' \
' "main": "index.js"' \
'}' > "$STAGE/server/node_modules/better-sqlite3/package.json"
printf '%s\n' \
'// Every require of better-sqlite3 in this bundle lands here, in whatever form it was written:' \
"// plain, require.resolve with paths, or an absolute path into node_modules." \
"module.exports = require('../../db/sqlite-compat.js');" \
> "$STAGE/server/node_modules/better-sqlite3/index.js"
echo " stub installed"
# ⚠️ ESM-ONLY PACKAGES DO NOT WORK INSIDE THE WIDGET.
#
# The player runs the server inside an Electron roHtmlWidget, and Electron's module loader does NOT
# implement require(ESM) - plain Node 24 does, which is why this only ever fails on hardware and
# never in a local test. An ESM-only dependency therefore gets compiled as CommonJS and dies on its
# first `export` keyword:
#
# [boot] Migration FAILED: Failed to construct 'ContextifyScript': Invalid or unexpected token
#
# uuid 14 is the one that bites immediately (21 files import it, including the multi-tenancy
# migration that runs on first boot), and it is trivially replaceable: only `v4` is used anywhere,
# and crypto.randomUUID() produces exactly that. So it gets the same treatment as better-sqlite3 -
# a CommonJS package installed under the real name, after npm, so every require resolves to it.
# ⚠️ STRIP SHEBANGS. Electron's module loader does not remove them; Node's does.
#
# Node's CJS loader strips a leading #! before wrapping a file in the module function, precisely
# because a shebang is not valid JavaScript. Electron's loader does not, so the wrapper it compiles
# is "(function(){#!/usr/bin/env node ...})" and V8 refuses it:
#
# [boot] Migration FAILED: Failed to construct 'ContextifyScript': Invalid or unexpected token
#
# Note the error names no token - "#" is not one. An ESM file compiled as CJS reports
# "Unexpected token 'export'" instead, which is how these two are told apart; chasing the ESM
# explanation for this error cost a full boot cycle.
#
# scripts/migrate-multitenancy.js is required during first boot, so it fails every time. Eight other
# shipped scripts carry the same line and would fail whenever something requires them.
#
# Done HERE and not in the source: those files are meant to be executable (./scripts/reset-admin.js),
# and the shebang is correct everywhere except inside this widget. The line is replaced by a comment
# of the same length rather than deleted, so line numbers in stack traces still match the source.
echo " stripping shebangs (Electron does not strip them; Node does)..."
python3 - "$STAGE" <<'SHEBANG'
import io, os, sys
stage = sys.argv[1]
fixed = []
for root, dirs, files in os.walk(stage):
if 'node_modules' in root.split(os.sep):
continue
for name in files:
if not name.endswith('.js'):
continue
path = os.path.join(root, name)
with io.open(path, 'rb') as fh:
head = fh.read(2)
if head != b'#!':
continue
rest = fh.read()
data = b'#!' + rest
nl = data.index(b'\n') if b'\n' in data else len(data)
# '//' + the rest of the shebang keeps the byte count and the line count identical.
with io.open(path, 'wb') as fh:
fh.write(b'//' + data[2:nl] + data[nl:])
fixed.append(os.path.relpath(path, stage))
for f in sorted(fixed):
print(" " + f)
print(" %d file(s)" % len(fixed))
SHEBANG
echo " installing a CommonJS uuid shim (Electron cannot require ESM)..."
mkdir -p "$STAGE/server/node_modules/uuid"
printf '%s\n' \
'{' \
' "name": "uuid",' \
' "version": "0.0.0-cjs-shim",' \
' "description": "Not the real uuid - a CommonJS facade over crypto.randomUUID, because Electron cannot require the ESM-only uuid 14.",' \
' "main": "index.js"' \
'}' > "$STAGE/server/node_modules/uuid/package.json"
printf '%s\n' \
"const { randomUUID } = require('crypto');" \
'// crypto.randomUUID() IS a v4 UUID (RFC 4122, random). Only v4 is used in this codebase; the' \
'// others are present so an accidental import fails loudly rather than silently returning undefined.' \
'const v4 = () => randomUUID();' \
"const notImplemented = (name) => () => { throw new Error('uuid.' + name + ' is not available in the BrightSign build (CJS shim provides v4 only)'); };" \
'module.exports = { v4, default: { v4 },' \
" v1: notImplemented('v1'), v3: notImplemented('v3'), v5: notImplemented('v5')," \
" v6: notImplemented('v6'), v7: notImplemented('v7')," \
" validate: (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(s)) };" \
> "$STAGE/server/node_modules/uuid/index.js"
node -e "const u=require('$STAGE/server/node_modules/uuid'); const v=u.v4();
if(!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)) { console.error('ERROR: uuid shim does not produce a v4 UUID: '+v); process.exit(1); }
if(!u.validate(v)) { console.error('ERROR: uuid shim validate() rejects its own output'); process.exit(1); }
console.log(' shim produces valid v4 UUIDs (' + v + ')');"
# What is still ESM-only, and therefore still a landmine on this platform? Report it rather than
# pretend it is handled - these fail only when their code path is first exercised on the player.
echo " remaining ESM-only packages (fine under Node, FAIL inside the widget when first required):"
# Fed by heredoc, not `node -e '...'`: the scan needs both quote characters and nesting them
# inside a single-quoted shell argument mangles them.
node - "$STAGE" <<'ESMSCAN'
const fs = require("fs"), path = require("path");
const root = path.join(process.argv[2], "server", "node_modules");
const out = [];
const scan = (dir, depth) => {
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
for (const d of entries) {
if (!d.isDirectory()) continue;
if (d.name.startsWith("@") && depth === 0) { scan(path.join(dir, d.name), 1); continue; }
const pj = path.join(dir, d.name, "package.json");
if (!fs.existsSync(pj)) continue;
let j;
try { j = JSON.parse(fs.readFileSync(pj, "utf8")); } catch (e) { continue; }
if (j.type !== "module") continue;
// A CommonJS entry point, or a "require" condition in exports, means require() still works.
if (j.main && !String(j.main).endsWith(".mjs")) continue;
if (JSON.stringify(j.exports || "").includes(JSON.stringify("require"))) continue;
out.push((j.name || d.name) + "@" + (j.version || "?"));
}
};
scan(root, 0);
if (!out.length) console.log(" none");
else out.sort().forEach((n) => console.log(" " + n));
ESMSCAN
echo " verifying the bundle is architecture-independent..."
if find "$STAGE" -name '*.node' -print -quit | grep -q .; then
echo "ERROR: native binaries in the bundle — this cannot run on the player:" >&2
find "$STAGE" -name '*.node' | sed 's/^/ /' >&2
exit 1
fi
if ! grep -q "node-sqlite-shim" "$STAGE/server/node_modules/better-sqlite3/package.json" 2>/dev/null; then
echo "ERROR: better-sqlite3 is the REAL package, not the shim — npm must have reinstalled it." >&2
exit 1
fi
echo " no .node binaries, better-sqlite3 is the shim — portable"
# THE SECOND INVARIANT, and the one that matters more. This package gets copied onto hardware that
# leaves the building. The first build of it contained a real 33MB customer database, 105MB of
# uploads, the TLS certs and .env — because the staging step used an exclude list instead of git.
# Verified rather than trusted, because "we removed it" is not a property, it is a memory.
# Verified, not assumed: one surviving shebang is one boot failure with a misleading error.
LEFTOVER="$(find "$STAGE" -name '*.js' -not -path '*/node_modules/*' -exec sh -c 'head -c2 "$1" | grep -q "#!" && echo "$1"' _ {} \; )"
if [ -n "$LEFTOVER" ]; then
echo "ERROR: shebangs remain; these will fail to compile inside the widget:" >&2
echo "$LEFTOVER" | sed 's/^/ /' >&2
exit 1
fi
echo " no shebangs remain in shipped scripts"
echo " verifying no data or secrets are bundled..."
LEAKS="$(find "$STAGE" \( -name '*.db' -o -name '*.db-wal' -o -name '*.db-shm' -o -name '.env' \
-o -name '.env.*' -o -name '*.pem' -o -name '*.key' -o -name '*.devbak' \) \
-not -path '*/node_modules/*' -print)"
if [ -n "$LEAKS" ]; then
echo "ERROR: the bundle contains data or secrets:" >&2
echo "$LEAKS" | sed 's/^/ /' >&2
exit 1
fi
for d in uploads certs data; do
if [ -d "$STAGE/server/$d" ] && [ -n "$(ls -A "$STAGE/server/$d" 2>/dev/null)" ]; then
echo "ERROR: server/$d is non-empty in the bundle — that is runtime state, not source." >&2
exit 1
fi
done
echo " no databases, uploads, certs or .env"
mkdir -p "$(dirname "$OUT")"
rm -f "$OUT"
ABS_OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
# -0 = STORED, for the same reason as the player package: BrightSign's deployment reported our
# first DEFLATED archive as invalid, and roBrightPackage documents "no compression" as the
# universally safe option. A compressed archive copies across perfectly and then fails to open.
( cd "$STAGE" && zip -q -r -X -0 "$ABS_OUT" . )
echo " built $OUT ($(du -h "$ABS_OUT" | cut -f1))"
# Listed ONCE into a variable. `unzip -l | grep -q` looks obvious and is a trap here: grep -q exits
# at the first match, unzip dies of SIGPIPE, and under `set -o pipefail` the pipeline reports
# failure — so a file that IS present is reported missing. With 9000+ entries it fires every time.
LISTING="$(unzip -l "$OUT")"
echo "$LISTING" | tail -1 | sed 's/^/ /'
REQUIRED="autorun.brs autozip.brs bs-server-boot.js node-server.html"
# The payload is verified on the thing the installer actually looks for before it commits the
# extraction. An archive that unpacks perfectly and lacks this is the failure worth catching here.
[ "$PAYLOAD_ONLY" = 1 ] && REQUIRED="server/server.js"
for required in $REQUIRED; do
case "$LISTING" in
*" $required"*) ;;
*) echo "ERROR: $required missing from the archive" >&2; exit 1 ;;
esac
done
COMPRESSED="$(unzip -v "$OUT" | awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[A-Za-z]/ && $2 != "Stored" {print $2}' | head -1)"
if [ -n "$COMPRESSED" ]; then
echo "ERROR: archive contains compressed members ($COMPRESSED); BrightSign needs it stored." >&2
exit 1
fi
echo " root-level layout verified, all members stored"

View file

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

View file

@ -1,9 +1,6 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// NOT fs.copyFileSync: the data directory is exFAT on a player and copyFileSync's fchmod is
// refused there, which killed the snapshot below and with it the server. See lib/fsutil.js.
const { copyFileBytes } = require('../lib/fsutil');
const config = require('../config'); const config = require('../config');
const { chunkedDelete, yieldTick, currentBand } = require('../lib/chunked-prune'); // #146 non-blocking sweeps const { chunkedDelete, yieldTick, currentBand } = require('../lib/chunked-prune'); // #146 non-blocking sweeps
@ -40,7 +37,7 @@ function ensureMultitenancyMigration() {
const snapshotPath = path.join(dbDir, `remote_display.pre-migration-${ts}.db`); const snapshotPath = path.join(dbDir, `remote_display.pre-migration-${ts}.db`);
try { try {
db.pragma('wal_checkpoint(TRUNCATE)'); db.pragma('wal_checkpoint(TRUNCATE)');
copyFileBytes(config.dbPath, snapshotPath); fs.copyFileSync(config.dbPath, snapshotPath);
console.warn(`[boot] Pre-migration snapshot: ${snapshotPath}`); console.warn(`[boot] Pre-migration snapshot: ${snapshotPath}`);
} catch (e) { } catch (e) {
console.error(`[boot] Snapshot failed: ${e.message}`); console.error(`[boot] Snapshot failed: ${e.message}`);
@ -521,12 +518,6 @@ const migrations = [
// Which physical output this row paints. A dual-output player runs one player per connector and // Which physical output this row paints. A dual-output player runs one player per connector and
// registers as two devices; without this they are indistinguishable in the dashboard. // registers as two devices; without this they are indistinguishable in the dashboard.
"ALTER TABLE devices ADD COLUMN output_index INTEGER", "ALTER TABLE devices ADD COLUMN output_index INTEGER",
// The attached panel's RAW EDID, base64. Stored raw and parsed on read (lib/edid.js) rather than
// exploded into columns: the blob is ~128-256 bytes, and every future field — gamma, the DTD mode
// list, the CEA blocks — then costs a server deploy instead of a migration AND a fleet update.
// That matters here more than usual: the bridge that collects it sits behind a CDN, and the host
// script only changes via an OTA package.
"ALTER TABLE devices ADD COLUMN hardware_edid TEXT",
// What the player says it can do, as a JSON array (see lib/player-capabilities.js). NULL means // What the player says it can do, as a JSON array (see lib/player-capabilities.js). NULL means
// the panel has never declared — the overwhelming majority of the fleet on the day this ships — // the panel has never declared — the overwhelming majority of the fleet on the day this ships —
// and resolves to a per-platform baseline. That NULL is load bearing: an empty array is a player // and resolves to a per-platform baseline. That NULL is load bearing: an empty array is a player
@ -1130,7 +1121,7 @@ function backfillPlaylistItemsZoneId() {
const snapshotPath = path.join(dbDir, `remote_display.pre-zone-id-backfill-${ts}.db`); const snapshotPath = path.join(dbDir, `remote_display.pre-zone-id-backfill-${ts}.db`);
try { try {
db.pragma('wal_checkpoint(TRUNCATE)'); db.pragma('wal_checkpoint(TRUNCATE)');
copyFileBytes(config.dbPath, snapshotPath); fs.copyFileSync(config.dbPath, snapshotPath);
console.warn(`[zone-id backfill] Pre-migration snapshot: ${snapshotPath}`); console.warn(`[zone-id backfill] Pre-migration snapshot: ${snapshotPath}`);
} catch (e) { } catch (e) {
console.error(`[zone-id backfill] Snapshot failed: ${e.message}`); console.error(`[zone-id backfill] Snapshot failed: ${e.message}`);
@ -1226,7 +1217,7 @@ const { applyTenantDeleteCascade } = require('../lib/tenant-cascade-migration');
let snapped = false; let snapped = false;
try { try {
db.pragma('wal_checkpoint(TRUNCATE)'); db.pragma('wal_checkpoint(TRUNCATE)');
copyFileBytes(config.dbPath, snapshotPath); fs.copyFileSync(config.dbPath, snapshotPath);
snapped = true; snapped = true;
} catch (e) { } catch (e) {
console.error(`[tenant-cascade] Snapshot failed: ${e.message}`); console.error(`[tenant-cascade] Snapshot failed: ${e.message}`);

View file

@ -1,212 +0,0 @@
'use strict';
/*
* A better-sqlite3-shaped façade over Node's built-in node:sqlite.
*
* WHY THIS EXISTS. BrightSignOS 10 ships /usr/bin/node v24.15.0, and Node 24 has node:sqlite built
* in. better-sqlite3 is the last native dependency in the tree; on a player it is the only thing
* that would need a cross-compiled binary, and it is the one piece that can turn a deploy into a
* node-gyp build on a device with three slow cores. Dropping to the built-in removes that whole
* class of problem no ABI, no prebuild matrix, no preflight rebuild, no toolchain.
*
* WHY A SHIM RATHER THAN A REWRITE. The API is 95% identical where it matters. There are 1501
* db.prepare() call sites in this server and every one of them uses .get/.all/.run, which node:sqlite
* provides with the same shapes including bare named parameters, which is the thing that would
* otherwise have forced a sweep. Exactly three things are missing: .pragma(), .transaction() and
* .pluck(). Those are 57, 45 and 2 call sites respectively, and they are all mechanical. A façade
* turns a 1600-site migration into a 100-line file.
*
* FOREIGN KEYS: THE ONE REAL BEHAVIOUR CHANGE.
*
* node:sqlite turns foreign key enforcement ON by default; better-sqlite3 leaves it at SQLite's
* default, which is OFF. This database has been running with them OFF, which is why
* pruneProvisioningDevices() silently orphans child rows instead of cascading the declared
* ON DELETE CASCADEs are inert today.
*
* So this defaults to OFF: matching what the data has always experienced. Turning them on is a
* REAL change to deletion semantics across every table with a declared cascade, and it deserves to
* be its own change, with its own soak, rather than a silent side effect of swapping a driver.
* Pass { enableForeignKeyConstraints: true } when you mean it.
*/
const { DatabaseSync } = require('node:sqlite');
/* Not implemented on purpose. Throwing beats silently doing something subtly different. */
function unsupported(name, why) {
return () => {
throw new Error(`sqlite-compat: ${name}() is not implemented${why ? `${why}` : ''}`);
};
}
class Statement {
constructor(stmt, sql) {
this._stmt = stmt;
this._pluck = false;
this.source = sql;
// better-sqlite3 accepts bare keys for :named / @named / $named parameters. node:sqlite can
// too, but only when asked — and this is what keeps the 1501 existing call sites untouched.
try { stmt.setAllowBareNamedParameters(true); } catch (e) { /* older node, already default */ }
}
/*
* Normalise the call shape, and the load-bearing part turn `undefined` into `null`.
*
* better-sqlite3 binds undefined as SQL NULL. node:sqlite REFUSES it:
* TypeError [ERR_INVALID_ARG_TYPE]: Provided value cannot be bound to SQLite parameter N.
*
* This server relies on the permissive behaviour, and there is a test that says so out loud
* ("undefined really does become NULL rather than throwing, so the write did succeed"). An
* optional field that simply is not present the overwhelmingly common case in device_info
* payloads arrives as undefined, and under the strict rule every one of those writes throws.
* Left unhandled it does not look like a binding bug: registration fails, the socket never
* completes, and a dozen unrelated timing tests fail four seconds later.
*
* Also handles a single array (better-sqlite3 accepts args bare or as one array) and named
* parameter objects, whose VALUES need the same treatment while the object itself must not be
* spread.
*/
_args(args) {
const list = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
return Array.prototype.map.call(list, (v) => {
if (v === undefined) return null;
if (v && typeof v === 'object' && !Buffer.isBuffer(v) && !ArrayBuffer.isView(v)) {
let copy = null;
for (const k of Object.keys(v)) {
if (v[k] === undefined) { copy = copy || { ...v }; copy[k] = null; }
}
return copy || v;
}
return v;
});
}
get(...args) {
const row = this._stmt.get(...this._args(args));
if (!this._pluck || row === undefined) return row;
const k = Object.keys(row);
return k.length ? row[k[0]] : undefined;
}
all(...args) {
const rows = this._stmt.all(...this._args(args));
if (!this._pluck) return rows;
return rows.map((r) => { const k = Object.keys(r); return k.length ? r[k[0]] : undefined; });
}
run(...args) {
// node:sqlite already returns { changes, lastInsertRowid } as NUMBERS, matching
// better-sqlite3's default (non-safeIntegers) behaviour. Verified, not assumed.
return this._stmt.run(...this._args(args));
}
iterate(...args) { return this._stmt.iterate(...this._args(args)); }
/* better-sqlite3 returns `this` so it chains: db.prepare(sql).pluck().get(id) */
pluck(toggle = true) { this._pluck = toggle !== false; return this; }
/* .raw() is setReturnArrays under a different name. */
raw(toggle = true) { this._stmt.setReturnArrays(toggle !== false); return this; }
columns() { return this._stmt.columns(); }
safeIntegers(toggle = true) { this._stmt.setReadBigInts(toggle !== false); return this; }
expand() { throw new Error('sqlite-compat: .expand() is not implemented (no call sites use it)'); }
}
class Database {
constructor(filename, options = {}) {
this._db = new DatabaseSync(filename, {
// See the header. Default OFF to match what this database has always run with; the swap to
// node:sqlite must not quietly change what DELETE does.
enableForeignKeyConstraints: options.enableForeignKeyConstraints === true,
...(options.readonly || options.readOnly ? { readOnly: true } : {}),
...(typeof options.timeout === 'number' ? { timeout: options.timeout } : {}),
});
this.name = filename;
this.open = true;
this._txDepth = 0;
}
prepare(sql) { return new Statement(this._db.prepare(sql), sql); }
exec(sql) { this._db.exec(sql); return this; }
/*
* better-sqlite3's .pragma(). Two forms are used in this codebase:
* db.pragma('foreign_keys = ON') -> a write, no result wanted
* db.pragma('foreign_keys', {simple:true}) -> a read of a single value
* The general form returns rows, as better-sqlite3 does.
*/
pragma(sql, options = {}) {
const text = `PRAGMA ${sql}`;
// An assignment has no result set. Running it through prepare().all() works for most pragmas
// but throws for some, so writes go through exec().
if (/=/.test(sql) && !options.simple) { this._db.exec(text); return undefined; }
const rows = this._db.prepare(text).all();
if (!options.simple) return rows;
if (!rows.length) return undefined;
const k = Object.keys(rows[0]);
return k.length ? rows[0][k[0]] : undefined;
}
/*
* better-sqlite3's .transaction(fn) returns a CALLABLE that runs fn inside a transaction and
* returns its value, rolling back on throw. Nesting matters: this codebase has 45 call sites and
* some nest, so an inner call must use a SAVEPOINT rather than a second BEGIN SQLite has no
* nested transactions and would throw "cannot start a transaction within a transaction".
*/
transaction(fn) {
if (typeof fn !== 'function') throw new TypeError('sqlite-compat: transaction() expects a function');
const self = this;
const wrapper = function (...args) {
const nested = self._txDepth > 0;
const name = `sp_${self._txDepth}`;
self._db.exec(nested ? `SAVEPOINT ${name}` : 'BEGIN');
self._txDepth++;
try {
const out = fn.apply(this, args);
self._txDepth--;
self._db.exec(nested ? `RELEASE ${name}` : 'COMMIT');
return out;
} catch (e) {
self._txDepth--;
try {
self._db.exec(nested ? `ROLLBACK TO ${name}` : 'ROLLBACK');
if (nested) self._db.exec(`RELEASE ${name}`);
} catch (e2) { /* the rollback itself failing must not mask the original error */ }
throw e;
}
};
// better-sqlite3 exposes these variants. Only the default is used here, but code that reaches
// for .immediate() should get a transaction rather than "undefined is not a function".
wrapper.default = wrapper;
wrapper.deferred = wrapper;
wrapper.immediate = wrapper;
wrapper.exclusive = wrapper;
return wrapper;
}
get inTransaction() { return this._txDepth > 0; }
function(name, ...rest) {
const fn = rest.pop();
const opts = rest.pop() || {};
return this._db.function(name, opts, fn);
}
aggregate(name, opts) { return this._db.aggregate(name, opts); }
close() { this.open = false; return this._db.close(); }
loadExtension(...a) { this._db.enableLoadExtension(true); return this._db.loadExtension(...a); }
serialize() { return this._db.serialize(); }
backup = unsupported('backup', 'node:sqlite has no online backup API; copy the file instead');
table = unsupported('table', 'virtual tables are not used here');
unsafeMode = unsupported('unsafeMode');
}
module.exports = Database;
module.exports.Database = Database;

View file

@ -76,7 +76,7 @@ function scheduleRespawn() {
// Degraded-but-safe: re-arm a conservative inline autocheckpoint on the MAIN connection so // Degraded-but-safe: re-arm a conservative inline autocheckpoint on the MAIN connection so
// the WAL can never grow unbounded, and reclaim the backlog the dead worker left behind. // the WAL can never grow unbounded, and reclaim the backlog the dead worker left behind.
function engageFallback(reason) { function engageFallback() {
if (fallbackEngaged) return; if (fallbackEngaged) return;
fallbackEngaged = true; fallbackEngaged = true;
try { mainDb.pragma(`wal_autocheckpoint = ${config.walCheckpointFallbackPages}`); } catch (_) {} try { mainDb.pragma(`wal_autocheckpoint = ${config.walCheckpointFallbackPages}`); } catch (_) {}
@ -88,7 +88,7 @@ function engageFallback(reason) {
// high-water mark, where leaving it is the worse of the two risks. // high-water mark, where leaving it is the worse of the two risks.
const over = walBytes() > config.walCheckpointHighWaterMB * 1024 * 1024; const over = walBytes() > config.walCheckpointHighWaterMB * 1024 * 1024;
try { mainDb.pragma(`wal_checkpoint(${over ? 'TRUNCATE' : 'PASSIVE'})`); } catch (_) {} try { mainDb.pragma(`wal_checkpoint(${over ? 'TRUNCATE' : 'PASSIVE'})`); } catch (_) {}
console.error(`[wal-checkpoint] ${reason || 'worker unrecoverable'} — re-enabled inline autocheckpoint as fallback (backlog reclaim: ${over ? 'TRUNCATE' : 'PASSIVE'})`); console.error(`[wal-checkpoint] worker unrecoverable — re-enabled inline autocheckpoint as fallback (backlog reclaim: ${over ? 'TRUNCATE' : 'PASSIVE'})`);
} }
// #240: the fallback is STICKY for the life of the process — once engaged, checkpoints are // #240: the fallback is STICKY for the life of the process — once engaged, checkpoints are
@ -120,28 +120,7 @@ function startWalCheckpointer(db, dbPath) {
// wal_autocheckpoint, so this still works at 0). Also reclaims any WAL a prior crash left. // wal_autocheckpoint, so this still works at 0). Also reclaims any WAL a prior crash left.
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) { /* best-effort */ } try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) { /* best-effort */ }
/*
* The worker may not be constructible AT ALL on some hosts.
*
* The respawn path below already catches a failed spawn, but this first one did not - and a
* throw here escapes startWalCheckpointer and takes the whole server down at boot:
*
* Failed to construct 'Worker': The V8 platform used by this instance of Node does not
* support creating Workers
*
* That is what an roHtmlWidget does: it is a Node context inside a renderer, and the renderer's
* V8 platform has no worker threads. The module already knows how to run without one -
* engageFallback() re-arms a conservative inline autocheckpoint - so the correct behaviour is
* to degrade into it rather than refuse to start. A checkpointer that cannot get a thread is a
* slower server; a checkpointer that throws is no server.
*/
try {
worker = spawnWorker(); worker = spawnWorker();
} catch (e) {
engageFallback(`worker threads unavailable on this platform (${e && e.message})`);
return null;
}
// #240: this line is where an operator learns the escalation policy, so it must state ALL of // #240: this line is where an operator learns the escalation policy, so it must state ALL of
// it. It advertised only "3 growing runs" after the size floor and the cooldown were added, // it. It advertised only "3 growing runs" after the size floor and the cooldown were added,
// which is the half that no longer holds on its own — and reading it during an incident would // which is the half that no longer holds on its own — and reading it during an incident would

View file

@ -29,59 +29,7 @@ const PACKAGE_FILES = ['autozip.brs', 'autorun.brs', 'offline.html', 'screentink
// sha256 rather than sha1 because that is the algorithm BrightScript's roMessageDigest is // 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 // 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. // 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 let cached = null; // { version, sha256, size, buffer }
// 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() { function brightsignDir() {
return path.join(__dirname, '..', '..', 'brightsign'); return path.join(__dirname, '..', '..', 'brightsign');
@ -114,11 +62,8 @@ function stampVersion(source, version) {
* Build the archive in memory. Entries are added in a fixed order with a fixed timestamp so the * 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 * bytes are reproducible: a checksum that changed on every server restart would make every player
* re-download the same package after every deploy. * 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) { function buildZip() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const dir = brightsignDir(); const dir = brightsignDir();
const chunks = []; const chunks = [];
@ -143,12 +88,6 @@ function buildZip(serverUrl) {
// unstamped and the player applies the update, still reports the old version, and is offered // 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. // 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'); 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. // date fixed for reproducibility; the player never reads it.
archive.append(body, { name, date: new Date(0) }); archive.append(body, { name, date: new Date(0) });
} }
@ -161,31 +100,25 @@ function buildZip(serverUrl) {
* deployment without the brightsign/ directory, for instance) callers must treat that as "no * 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". * manifest", which the update decision reads as "keep running", never as "wipe yourself".
*/ */
async function getPackage(serverUrl) { async function getPackage() {
const key = serverUrl || ''; if (cached) return cached;
const hit = cache.get(key);
if (hit) return hit;
const version = readVersion(); const version = readVersion();
if (!version) return null; if (!version) return null;
try { try {
const buffer = await buildZip(serverUrl || null); const buffer = await buildZip();
const built = { cached = {
version, version,
sha256: crypto.createHash('sha256').update(buffer).digest('hex'), sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
size: buffer.length, size: buffer.length,
buffer buffer
}; };
// Evict oldest-first. Map preserves insertion order, and the realistic working set is one or return cached;
// 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) { } catch (e) {
return null; return null;
} }
} }
/* Test seam: drop the cache so a changed file is picked up without a restart. */ /* Test seam: drop the cache so a changed file is picked up without a restart. */
function _reset() { cache.clear(); } function _reset() { cached = null; }
module.exports = { getPackage, packageServerUrl, _reset, PACKAGE_FILES }; module.exports = { getPackage, _reset, PACKAGE_FILES };

View file

@ -1,196 +0,0 @@
'use strict';
/*
* Parse a raw EDID block into the facts an operator actually asks about.
*
* WHY THIS LIVES ON THE SERVER, not in the player.
*
* The player can already answer a few of these: @brightsign/videooutput exposes getEdidIdentity(),
* which returns monitorName, product, serialNumber, weekOfManufacture, yearOfManufacture and the
* BT2020/HDR support flags and nothing else. Everything the player's own DWS shows beyond that
* (manufacturer, EDID version, physical size, gamma, the VESA/standard/DTD mode lists, the CEA
* extension) comes from parsing the raw bytes, which getEdid() hands over untouched.
*
* Shipping the ~128/256 raw bytes and parsing HERE means a new field is a server deploy, not a
* fleet update. That distinction is not theoretical on this platform: st-bridge.js sits behind a
* CDN that held it for four hours at a time, and autorun.brs only changes via an OTA package. A
* parser on the player would make "we also want gamma" cost a firmware round trip.
*
* It is also the only half that can be tested. Nothing that runs on the panel can be.
*
* Reference: VESA E-EDID 1.3/1.4, base block = 128 bytes; CEA-861 extension blocks follow.
*/
// Established timings bitmap, byte 35-36. Byte 37 is the "manufacturer reserved" set, ignored.
const ESTABLISHED = [
[35, 0x80, '720x400@70'], [35, 0x40, '720x400@88'], [35, 0x20, '640x480@60'],
[35, 0x10, '640x480@67'], [35, 0x08, '640x480@72'], [35, 0x04, '640x480@75'],
[35, 0x02, '800x600@56'], [35, 0x01, '800x600@60'],
[36, 0x80, '800x600@72'], [36, 0x40, '800x600@75'], [36, 0x20, '832x624@75'],
[36, 0x10, '1024x768@87i'], [36, 0x08, '1024x768@60'], [36, 0x04, '1024x768@70'],
[36, 0x02, '1024x768@75'], [36, 0x01, '1280x1024@75'],
];
const ASPECT = ['16:10', '4:3', '5:4', '16:9'];
/* The 3-letter PNP id is five bits per letter, big-endian, 'A' == 1. */
function manufacturer(buf) {
const v = buf.readUInt16BE(8);
const letter = (n) => String.fromCharCode(64 + (n & 0x1f));
return letter(v >> 10) + letter(v >> 5) + letter(v);
}
/* A Detailed Timing Descriptor, 18 bytes. Returns null for the descriptor-block forms. */
function detailedTiming(d) {
const pixelClock = d.readUInt16LE(0) * 10; // kHz
if (pixelClock === 0) return null; // 0 marks a monitor-descriptor, not a timing
const hActive = d[2] | ((d[4] & 0xf0) << 4);
const hBlank = d[3] | ((d[4] & 0x0f) << 8);
const vActive = d[5] | ((d[7] & 0xf0) << 4);
const vBlank = d[6] | ((d[7] & 0x0f) << 8);
const hTotal = hActive + hBlank;
const vTotal = vActive + vBlank;
const interlaced = !!(d[17] & 0x80);
// Rounded, because this is read by a human comparing it to what the panel claims — 59.94 and 60
// are the same answer to "is it running at the right rate".
const refresh = hTotal && vTotal ? Math.round((pixelClock * 1000) / (hTotal * vTotal)) : null;
return {
width: hActive,
height: vActive,
refresh,
interlaced,
pixelClockKhz: pixelClock,
widthMm: d[12] | ((d[14] & 0xf0) << 4),
heightMm: d[13] | ((d[14] & 0x0f) << 8),
label: `${hActive}x${vActive}${interlaced ? 'i' : ''}@${refresh}`,
};
}
/* Descriptor blocks 2-4 carry names and ranges instead of timings when the pixel clock is 0. */
function monitorDescriptor(d, out) {
const text = () => d.slice(5, 18).toString('ascii').split('\n')[0].trim();
switch (d[3]) {
case 0xfc: out.monitorName = text(); break;
case 0xff: out.serialNumberString = text(); break;
case 0xfe: out.textString = text(); break;
case 0xfd:
out.rangeLimits = {
vMinHz: d[5], vMaxHz: d[6], hMinKhz: d[7], hMaxKhz: d[8],
maxPixelClockMhz: d[9] ? d[9] * 10 : null,
};
break;
default: break; // 0xfa extra standard timings, 0xf7..0xf9 vendor — nothing an operator reads
}
}
/*
* CEA-861 extension. This is where the TV modes, audio support and the HDMI vendor block live
* i.e. where "BT2020 supported" on the DWS comes from.
*/
function parseCea(buf, out) {
if (buf.length < 128 || buf[0] !== 0x02) return;
out.cea = { revision: buf[1], underscan: !!(buf[3] & 0x80), basicAudio: !!(buf[3] & 0x40),
ycbcr444: !!(buf[3] & 0x20), ycbcr422: !!(buf[3] & 0x10), nativeFormats: buf[3] & 0x0f };
const dtdStart = buf[2];
if (dtdStart <= 4) return; // 0 = no data block collection, 4 = empty
let i = 4;
while (i < dtdStart && i < buf.length) {
const tag = buf[i] >> 5;
const len = buf[i] & 0x1f;
const body = buf.slice(i + 1, i + 1 + len);
if (tag === 3 && body.length >= 3) {
// Vendor-specific. 0x000C03 is the HDMI Licensing IEEE id — the HDMI VSDB.
const oui = body[0] | (body[1] << 8) | (body[2] << 16);
if (oui === 0x000c03) out.cea.hdmiVsdb = true;
if (oui === 0xc45dd8) out.cea.hdmiForumVsdb = true;
}
if (tag === 7 && body.length >= 2 && body[0] === 0x05) {
// Colorimetry data block: BT2020 flags live in the first payload byte.
out.cea.bt2020Rgb = !!(body[1] & 0x80);
out.cea.bt2020Ycc = !!(body[1] & 0x40);
out.cea.bt2020cYcc = !!(body[1] & 0x20);
}
if (tag === 7 && body.length >= 2 && body[0] === 0x06) {
// HDR static metadata: bit 2 is SMPTE ST 2084 (HDR10).
out.cea.hdrSt2084 = !!(body[1] & 0x04);
out.cea.hdrHlg = !!(body[1] & 0x08);
}
i += len + 1;
}
for (let d = dtdStart; d + 18 <= 127; d += 18) {
const t = detailedTiming(buf.slice(d, d + 18));
if (t) (out.detailedTimings = out.detailedTimings || []).push(t);
}
}
/*
* Parse. Returns null rather than throwing for anything unrecognisable: this runs on data a panel
* supplied, and a malformed EDID must degrade to "we do not know" rather than take down the device
* page that was only trying to show a label.
*/
function parseEdid(input) {
let buf = input;
if (typeof buf === 'string') {
const s = buf.trim();
buf = /^[0-9a-fA-F\s]+$/.test(s) && s.replace(/\s/g, '').length >= 256
? Buffer.from(s.replace(/\s/g, ''), 'hex')
: Buffer.from(s, 'base64');
} else if (Array.isArray(buf) || (buf && buf.buffer && !Buffer.isBuffer(buf))) {
buf = Buffer.from(buf); // Uint8Array or a plain array of byte values
}
if (!Buffer.isBuffer(buf) || buf.length < 128) return null;
// The fixed 8-byte header is the only reliable "this is an EDID" signal.
if (buf.readUInt32BE(0) !== 0x00ffffff || buf.readUInt32BE(4) !== 0xffffff00) return null;
const base = buf.slice(0, 128);
const sum = base.reduce((a, b) => (a + b) & 0xff, 0);
const out = {
manufacturer: manufacturer(base),
product: base.readUInt16LE(10),
productHex: '0x' + base.readUInt16LE(10).toString(16).padStart(4, '0'),
serialNumber: base.readUInt32LE(12),
weekOfManufacture: base[16],
yearOfManufacture: base[17] + 1990,
edidVersion: `${base[18]}.${base[19]}`,
digital: !!(base[20] & 0x80),
widthCm: base[21],
heightCm: base[22],
// Stored as (gamma*100)-100; 0xff means "defined in a descriptor instead".
gamma: base[23] === 0xff ? null : Math.round((base[23] + 100)) / 100,
checksumValid: sum === 0,
extensionBlocks: base[126],
establishedTimings: ESTABLISHED.filter(([o, m]) => base[o] & m).map(([, , label]) => label),
standardTimings: [],
detailedTimings: [],
};
for (let i = 38; i <= 52; i += 2) {
if (base[i] === 0x01 && base[i + 1] === 0x01) continue; // unused slot
const width = (base[i] + 31) * 8;
const aspect = ASPECT[base[i + 1] >> 6];
const refresh = (base[i + 1] & 0x3f) + 60;
const heights = { '16:10': (width * 10) / 16, '4:3': (width * 3) / 4, '5:4': (width * 4) / 5, '16:9': (width * 9) / 16 };
out.standardTimings.push({ width, height: Math.round(heights[aspect]), refresh, aspect,
label: `${width}x${Math.round(heights[aspect])}@${refresh}` });
}
for (let i = 54; i <= 108; i += 18) {
const d = base.slice(i, i + 18);
const t = detailedTiming(d);
if (t) out.detailedTimings.push(t);
else monitorDescriptor(d, out);
}
// The first DTD is the panel's preferred mode by definition — the one an installer means when
// they ask "what should this be set to".
out.preferredMode = out.detailedTimings.length ? out.detailedTimings[0].label : null;
for (let e = 1; e <= out.extensionBlocks && (e + 1) * 128 <= buf.length; e++) {
parseCea(buf.slice(e * 128, (e + 1) * 128), out);
}
return out;
}
module.exports = { parseEdid };

View file

@ -1,74 +0,0 @@
'use strict';
const fs = require('fs');
/*
* Copy a file without touching its mode.
*
* USE THIS INSTEAD OF fs.copyFileSync FOR ANYTHING UNDER THE DATA DIRECTORY.
*
* copyFileSync does not merely copy bytes: it opens the destination and then fchmods it to match
* the source. On exFAT there are no permission bits, so that chmod is refused and the whole copy
* fails with
*
* EPERM: operation not permitted, copyfile '.../remote_display.db' -> '.../...pre-migration.db'
*
* That is not hypothetical. A BrightSign player's storage is exFAT, and this took down the server
* running on one: the pre-migration snapshot in db/database.js failed, the failure path called
* process.exit(1), and because that server runs inside an roHtmlWidget the exit killed the page
* too a black screen, no listener, and no diagnostic anywhere. The check was right; the copy was
* the problem.
*
* Chunked rather than readFileSync/writeFileSync because the thing most often copied here is the
* database, which is unbounded in principle and 33MB in practice on a developer's machine.
*/
const CHUNK = 1024 * 1024;
function copyFileBytes(src, dest) {
const inFd = fs.openSync(src, 'r');
let outFd;
try {
// 'w' truncates or creates. No mode is requested, so nothing asks exFAT for permission bits.
outFd = fs.openSync(dest, 'w');
const buf = Buffer.allocUnsafe(CHUNK);
let pos = 0;
for (;;) {
const read = fs.readSync(inFd, buf, 0, CHUNK, pos);
if (read <= 0) break;
let written = 0;
// A single writeSync is not guaranteed to consume the whole buffer.
while (written < read) written += fs.writeSync(outFd, buf, written, read - written);
pos += read;
}
/*
* Carry the source's permissions across where the filesystem has any.
*
* NOT optional, and the reason this function exists does not excuse skipping it. Dropping
* the chmod entirely - which is what the first version did - creates the copy at the default
* 0666 & ~umask. A database snapshot that was 0600 came out 0664, so the whole database became
* group- and world-readable on every install. That is a worse bug than the one this function
* was written to fix.
*
* Doing it as a SEPARATE, failure-tolerant step is the difference from fs.copyFileSync: there
* the chmod is inseparable from the copy, so a filesystem that refuses modes - exFAT, which is
* what a BrightSign player's storage is - fails the whole operation with EPERM. Here the bytes
* are already written and safe; the mode is applied if it can be, and its refusal is not an
* error because on such a filesystem there were never permissions to preserve.
*/
try {
fs.fchmodSync(outFd, fs.fstatSync(inFd).mode & 0o777);
} catch (e) {
/* no permission bits on this filesystem - nothing to carry across */
}
// The caller is usually taking a backup it is about to rely on, so make sure the bytes are
// actually on the device before it proceeds to modify the original.
fs.fsyncSync(outFd);
return pos;
} finally {
fs.closeSync(inFd);
if (outFd !== undefined) fs.closeSync(outFd);
}
}
module.exports = { copyFileBytes };

View file

@ -190,25 +190,9 @@ const BASELINE = {
// NOT system.reboot / display.power / display.resolution / system.self_update: all four are // NOT system.reboot / display.power / display.resolution / system.self_update: all four are
// BrightScript calls through a bridge this unit is not known to have. // BrightScript calls through a bridge this unit is not known to have.
// //
// NOT remote.screenshot / remote.stream — but the ORIGINAL reason is now wrong, so read this // NOT remote.screenshot / remote.stream: a canvas capture on a hwz player cannot read the video
// before restoring them. The old note said a canvas capture on a hwz player cannot read the // plane, so it returns a frame with a hole where the content is. (audio.volume moved INTO the
// video plane and returns a frame with a hole. Two things changed: // list above in 1.9.31 — the payload it was waiting on now lands.)
//
// 1. st-bridge.js gained captureScreen(), which uses the native @brightsign/screenshot module
// and DOES composite the hardware plane. Confirmed on hardware (XT245 / BOS 10.0.16). It
// needs only require(), not a host bridge — so it works on the exact units the note feared.
// 2. The canvas fallback is no longer silent: renderCaptureCanvas() paints "Video is playing
// on the hardware plane and cannot be captured" rather than a black rectangle.
//
// They stay out for a DIFFERENT and narrower reason: captureScreen() needs a node-enabled
// widget. A widget built by the BSN Supervisor has no require(), so there the path really does
// end at a canvas that cannot see the video. This baseline cannot tell the two apart.
//
// Restoring them would also be close to a no-op: server/player/index.html declares remote.stream
// unconditionally and remote.screenshot behind an always-true canvas check, and platform=
// 'brightsign' is only ever set by the same register that carries the declaration — so a
// BrightSign row essentially always HAS one and never reaches this baseline. (audio.volume moved
// INTO the list above in 1.9.31 — the payload it was waiting on now lands.)
], ],
// A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or // A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or
// capture anything outside its own document. // capture anything outside its own document.

View file

@ -1 +0,0 @@
/home/owner/Downloads/remote_display/server/node_modules

View file

@ -1,13 +1,12 @@
{ {
"name": "screentinker", "name": "screentinker",
"version": "1.9.36", "version": "1.9.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "screentinker", "name": "screentinker",
"version": "1.9.36", "version": "1.9.35",
"license": "MIT",
"dependencies": { "dependencies": {
"@azure/msal-node": "^5.2.1", "@azure/msal-node": "^5.2.1",
"@jsquash/avif": "^1.3.0", "@jsquash/avif": "^1.3.0",

View file

@ -1,7 +1,6 @@
{ {
"name": "screentinker", "name": "screentinker",
"version": "1.9.36", "version": "1.9.35",
"license": "MIT",
"description": "ScreenTinker - Digital Signage Management Server", "description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View file

@ -1534,14 +1534,6 @@
// socket.io Manager (io opts below). NO status/health poll — load is read from ack-silence. // socket.io Manager (io opts below). NO status/health poll — load is read from ack-silence.
// Browser-specific half-open triggers (visibility/resume/online) drive the SAME check + the // Browser-specific half-open triggers (visibility/resume/online) drive the SAME check + the
// #148 teardown-first reconnect — additional triggers, not a separate mechanism. // #148 teardown-first reconnect — additional triggers, not a separate mechanism.
// ST_PLAYER_VERSION — stamped at SERVE time by the /player route from the repo VERSION, the
// same trick autorun.brs uses for its package version. It was a hardcoded '1.1.0-web' that
// nobody bumped for the whole 1.x line, so every web, Tizen and BrightSign panel in the fleet
// reported a version three years stale as its client_version. Do not edit by hand.
//
// No '-web' suffix: client_version is only ever compared for EQUALITY (server/lib/liveness.js),
// but a X.Y.Z-web string is a semver PRERELEASE and sorts BELOW X.Y.Z, so the moment anything
// orders it the value would read as older than the release it came from.
const PLAYER_VERSION = '1.1.0-web'; const PLAYER_VERSION = '1.1.0-web';
const V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000; const V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000;
function v4ThresholdMs(rand) { return V4_THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * V4_THRESHOLD_JITTER_MS); } function v4ThresholdMs(rand) { return V4_THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * V4_THRESHOLD_JITTER_MS); }
@ -2170,72 +2162,6 @@
return caps; return caps;
} }
// The version of the HOST package (autorun.brs) this player is running, or null off-platform
// and before the host has said. It is stamped into autorun.brs at build time and arrives here
// via a host-telemetry message, so it is NOT available synchronously at first register — see
// maybeReportAppVersion().
//
// typeof, not truthiness, for the same reason the heartbeat does it: page and bridge are
// fetched separately, so a new page can run against a CACHED older bridge that lacks this
// method, and calling it would throw out of registration entirely.
function hostPackageVersion() {
try {
if (!BS || typeof BS.telemetrySnapshot !== 'function') return null;
const v = BS.telemetrySnapshot().package_version;
return typeof v === 'string' && v.trim() ? v.trim() : null;
} catch (e) { return null; }
}
// ⚠️ Always build the WHOLE blob. The server's applyDeviceInfo() UPDATEs every column it
// covers unconditionally, so a partial device_info does not "patch" a row — it nulls
// android_version and the screen dimensions and resets ota_status/tier/the flag columns.
// device:register and device:info both land there, so both must send the full thing.
function buildDeviceInfo() {
return {
android_version: 'Web/' + navigator.userAgent.split(' ').pop(),
// On a BrightSign, app_version is the ON-DEVICE host package — the artifact OTA replaces
// and the only one here that can be stale. PLAYER_VERSION describes this page, which the
// server serves fresh every load, and it already travels as client_version. Reporting the
// page version in both columns is what made an XT245 read as 1.1.0-web forever, with no
// way to tell a freshly-provisioned host from a year-old one.
app_version: hostPackageVersion() || PLAYER_VERSION,
screen_width: screen.width,
screen_height: screen.height,
};
}
// What we last told the server, so a change is detectable. register() re-seeds this with
// whatever it actually sent — null when the host had not spoken yet, which is precisely the
// case maybeReportAppVersion() exists to repair.
let reportedAppVersion = null;
// The host package version is not known at first register: SendHostTelemetry runs on the
// autorun's own schedule and can land after this page has already registered. Rather than
// delay registration on it — which would trade a cosmetic gap for a real one — register with
// what we have and correct the record when the host speaks up. Also covers the version
// CHANGING under a live page, which is exactly what a self-update does, and which the
// server turns into an 'upgrade' incident.
// The EDID probe is asynchronous and can answer after this page has already registered, so the
// first register often carries nothing. It travels on the REGISTER path (applyHardwareIdentity)
// rather than device:info, so correcting it means registering again — once, when the value
// first appears. Guarded by a flag because re-registering on every heartbeat would be absurd.
let reportedEdid = false;
function maybeReportEdid() {
if (reportedEdid || !BS || typeof BS.edid !== 'function') return;
let v = null;
try { v = BS.edid(); } catch (e) { return; }
if (!v) return; // still probing, or no panel on this output
reportedEdid = true;
register();
}
function maybeReportAppVersion() {
const v = hostPackageVersion();
if (!v || v === reportedAppVersion) return;
reportedAppVersion = v;
socket.emit('device:info', { device_id: config.deviceId, device_info: buildDeviceInfo() });
}
function register() { function register() {
const data = {}; const data = {};
// #163: always send device identity when we have it, regardless of paired // #163: always send device identity when we have it, regardless of paired
@ -2258,10 +2184,12 @@
} }
data.pairing_code = config.pairingCode; data.pairing_code = config.pairingCode;
} }
data.device_info = buildDeviceInfo(); data.device_info = {
// Record what this register just carried, so maybeReportAppVersion() only speaks when the android_version: 'Web/' + navigator.userAgent.split(' ').pop(),
// host actually tells us something we have not already sent. app_version: '1.1.0-web',
reportedAppVersion = hostPackageVersion(); screen_width: screen.width,
screen_height: screen.height,
};
// v4 client identity block — additive, canonical snake_case (same shape as APK/.wgt so the // v4 client identity block — additive, canonical snake_case (same shape as APK/.wgt so the
// server consumes one thing). Backward-compatible: an old server ignores unknown fields. // server consumes one thing). Backward-compatible: an old server ignores unknown fields.
data.client_type = 'player'; data.client_type = 'player';
@ -2279,11 +2207,6 @@
// own widget with &screen=2, so two rows from one player stay distinguishable. // own widget with &screen=2, so two rows from one player stay distinguishable.
data.bs_screen = BS.screen(); data.bs_screen = BS.screen();
data.sync_backend = BS.syncBackend(); data.sync_backend = BS.syncBackend();
// The attached panel's raw EDID (base64), parsed server-side. Identity, not telemetry:
// it changes when someone swaps the screen, so it rides the register next to bs_model
// and bs_serial rather than the 15s heartbeat. typeof-guarded because the bridge is
// fetched separately from this page and may predate the method.
if (typeof BS.edid === 'function') data.bs_edid = BS.edid() || null;
} catch (e) { /* identity extras are additive — never block registration */ } } catch (e) { /* identity extras are additive — never block registration */ }
} }
// What this instance can actually do. The server persists it and the dashboard hides the // What this instance can actually do. The server persists it and the dashboard hides the
@ -2304,11 +2227,6 @@
stopHeartbeat(); stopHeartbeat();
heartbeatTimer = setInterval(() => { heartbeatTimer = setInterval(() => {
if (!socket?.connected || !config.deviceId) return; if (!socket?.connected || !config.deviceId) return;
// Piggy-backed on the heartbeat rather than given its own timer: this is the one loop
// already guaranteed to be running whenever the socket is up, and the host version is a
// fact that changes at most once per update.
maybeReportAppVersion();
maybeReportEdid();
socket.emit('device:heartbeat', { socket.emit('device:heartbeat', {
device_id: config.deviceId, device_id: config.deviceId,
client_ms: Date.now(), // #group-sync: t1 for NTP-style clock discipline (echoed in the ack) client_ms: Date.now(), // #group-sync: t1 for NTP-style clock discipline (echoed in the ack)

View file

@ -188,12 +188,7 @@ router.get('/:id', (req, res) => {
// or ~440 existing displays lose their controls the moment this ships. // or ~440 existing displays lose their controls the moment this ships.
const capabilities = playerCapabilities.capabilitiesFor(device); const capabilities = playerCapabilities.capabilitiesFor(device);
// Parsed on READ, not on receipt. The raw block stays the stored truth, so adding a field later res.json({ ...stripDeviceSecrets(device), capabilities, telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents });
// is a server deploy rather than re-collecting from every panel in the field. Null for a device
// that never reported one, or whose block is unreadable — the card simply does not render.
const edid = require('../lib/edid').parseEdid(device.hardware_edid);
res.json({ ...stripDeviceSecrets(device), capabilities, edid, telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents });
}); });
// Helper: check device write access via the workspace the device belongs to. // Helper: check device write access via the workspace the device belongs to.

View file

@ -60,52 +60,6 @@ function getWorkspaceSchedulesQuery() {
`; `;
} }
/*
* A recurring instance must go out in the SAME shape a one-off does: a naive wall-clock string.
*
* THIS IS THE "MY SCHEDULE IS ON THE WRONG DAY" BUG.
*
* expandSchedule had two emit paths that disagreed. A one-off returns schedule.start_time
* untouched - a naive local string like 2026-08-19T20:00:00 - which the browser parses in ITS OWN
* zone, giving back the day the operator picked. A recurring instance returned cursor.toISOString(),
* an absolute instant derived by reading that same naive string in the SERVER's zone. The browser
* then converted it back into its own zone, and the two conversions do not cancel:
*
* operator in Tokyo saves Wed 20:00 -> stored "2026-08-19T20:00:00"
* server in US Central reads 20:00 CDT -> emits "2026-08-20T01:00:00.000Z"
* browser renders that in JST -> THURSDAY 10:00
*
* Day and time both wrong, and only for recurring schedules - which is why it looked intermittent.
* The calendar was also the odd one out: the playback engine compares start_time as a STRING
* (services/scheduler.js), never as an instant, so expandSchedule was the only place in the
* codebase reinterpreting a wall-clock time as a point in time.
*
* schedules.timezone is already stored per row; rendering true instants from it would be the fuller
* answer. Matching the format the rest of the system already speaks fixes the bug in front of us
* and cannot change what the engine actually plays.
*/
function localDateTime(d) {
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` +
`T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
// The week calendar sends a local YYYY-MM-DD, which represents the date shown in the browser —
// not a UTC instant. Construct from numeric local parts instead of new Date('YYYY-MM-DD') because
// that ISO form is specified as UTC and becomes the preceding day on servers west of Greenwich.
// Full timestamps remain accepted for older browser clients.
function parseCalendarDate(value) {
if (!value) return new Date();
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return new Date(value);
const [, year, month, day] = match.map(Number);
const parsed = new Date(year, month - 1, day);
return parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day
? parsed
: new Date(NaN);
}
// Load a schedule + access context, sending 403/404 on failure. // Load a schedule + access context, sending 403/404 on failure.
function loadScheduleAccess(req, res, requireWrite) { function loadScheduleAccess(req, res, requireWrite) {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
@ -219,8 +173,7 @@ router.get('/week', (req, res) => {
const ctx = workspaceAccess(req, scopeWorkspaceId); const ctx = workspaceAccess(req, scopeWorkspaceId);
if (!ctx) return res.status(403).json({ error: 'Access denied' }); if (!ctx) return res.status(403).json({ error: 'Access denied' });
const weekStart = parseCalendarDate(date); const weekStart = date ? new Date(date) : new Date();
if (Number.isNaN(weekStart.getTime())) return res.status(400).json({ error: 'Invalid calendar date' });
weekStart.setHours(0, 0, 0, 0); weekStart.setHours(0, 0, 0, 0);
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); weekStart.setDate(weekStart.getDate() - weekStart.getDay());
const weekEnd = new Date(weekStart); const weekEnd = new Date(weekStart);
@ -477,8 +430,8 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
if (fires && (cursor >= rangeStart || instanceEnd >= rangeStart)) { if (fires && (cursor >= rangeStart || instanceEnd >= rangeStart)) {
events.push({ events.push({
...schedule, ...schedule,
instance_start: localDateTime(cursor), instance_start: cursor.toISOString(),
instance_end: localDateTime(instanceEnd), instance_end: instanceEnd.toISOString(),
}); });
} }
cursor = new Date(cursor.getTime() + dayMs); cursor = new Date(cursor.getTime() + dayMs);
@ -511,5 +464,3 @@ module.exports = router;
// Exported for testing, the same way playlists.js exports publishPlaylist. The calendar's // Exported for testing, the same way playlists.js exports publishPlaylist. The calendar's
// correctness is arithmetic and deserves to be checked without standing up a server. // correctness is arithmetic and deserves to be checked without standing up a server.
module.exports.expandSchedule = expandSchedule; module.exports.expandSchedule = expandSchedule;
module.exports.localDateTime = localDateTime;
module.exports.parseCalendarDate = parseCalendarDate;

View file

@ -3,7 +3,6 @@ const router = express.Router();
const { db } = require('../db/database'); const { db } = require('../db/database');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { copyFileBytes } = require('../lib/fsutil'); // exFAT-safe; see lib/fsutil.js
const fs = require('fs'); const fs = require('fs');
const config = require('../config'); const config = require('../config');
const { sixDigitCode } = require('../lib/numeric-code'); const { sixDigitCode } = require('../lib/numeric-code');
@ -363,7 +362,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const destName = `${newId}${ext}`; const destName = `${newId}${ext}`;
const destPath = path.join(config.contentDir, destName); const destPath = path.join(config.contentDir, destName);
try { try {
copyFileBytes(f.path, destPath); fs.copyFileSync(f.path, destPath);
// Match original filepath vs thumbnail // Match original filepath vs thumbnail
if (c.original_filepath && f.name === c.original_filepath) { if (c.original_filepath && f.name === c.original_filepath) {
newFilepath = destName; newFilepath = destName;

View file

@ -315,20 +315,6 @@ app.get(['/player', '/player/', '/player/index.html'], (req, res) => {
} else { } else {
modified = html.replace('</head>', inject + '</head>'); modified = html.replace('</head>', inject + '</head>');
} }
// Stamp the page's own version, so client_version tracks the release that served it instead of
// a literal nobody bumps. Anchored on the ST_PLAYER_VERSION marker rather than the old value,
// so a hand-edited default cannot cause a silent miss — and if the marker is ever removed we
// say so loudly, because the failure is otherwise invisible: every panel just keeps reporting
// a stale version and looks fine.
const stamped = modified.replace(
/(const PLAYER_VERSION = )'[^']*'/,
`$1'${String(VERSION).replace(/'/g, '')}'`
);
if (stamped === modified) {
console.warn('[player] ST_PLAYER_VERSION marker not found — page will report a stale client_version');
}
modified = stamped;
res.type('html').setHeader('Cache-Control', 'no-cache'); res.type('html').setHeader('Cache-Control', 'no-cache');
res.send(modified); res.send(modified);
}); });
@ -386,10 +372,7 @@ const bsUpdate = require('./lib/brightsign-update');
// tested place instead of being re-implemented in BrightScript where it cannot be tested at all. // tested place instead of being re-implemented in BrightScript where it cannot be tested at all.
// The host does only what it is told. // The host does only what it is told.
app.get('/api/brightsign/package', async (req, res) => { app.get('/api/brightsign/package', async (req, res) => {
// Same derivation as the download route below — they MUST agree, or the manifest advertises a const pkg = await bsPackage.getPackage();
// checksum for bytes the player never receives, which is the OTA loop this module exists to
// prevent. One helper, called from both.
const pkg = await bsPackage.getPackage(bsPackage.packageServerUrl(req));
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
// No package (a deployment without brightsign/, or an unreadable VERSION) is reported as a // No package (a deployment without brightsign/, or an unreadable VERSION) is reported as a
@ -418,7 +401,7 @@ app.get('/api/brightsign/package', async (req, res) => {
}); });
app.get('/api/brightsign/package/download', async (req, res) => { app.get('/api/brightsign/package/download', async (req, res) => {
const pkg = await bsPackage.getPackage(bsPackage.packageServerUrl(req)); const pkg = await bsPackage.getPackage();
if (!pkg) return res.status(404).type('text/plain').send('package unavailable'); if (!pkg) return res.status(404).type('text/plain').send('package unavailable');
res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', String(pkg.size)); res.setHeader('Content-Length', String(pkg.size));
@ -1000,13 +983,7 @@ app.use('/api/status', require('./routes/status'));
* TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither. * TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither.
*/ */
if (process.env.TELEMETRY_COLLECTOR === '1') { if (process.env.TELEMETRY_COLLECTOR === '1') {
/* `require('./db/database').db`, not the module-scope `db` that binding is declared far app.use('/api', require('./routes/telemetry-collector')(db));
below this line, so naming it here throws "Cannot access 'db' before initialization" at
load and the process never starts. The inline handler this replaced only touched `db`
inside a request callback, which runs long after the binding exists; passing it to a
factory made the reference eager. Every neighbouring call site in this region resolves
the same lazy way. */
app.use('/api', require('./routes/telemetry-collector')(require('./db/database').db));
console.log('[telemetry] collector enabled at POST /api/telemetry/report (+ GET /api/public/stats)'); console.log('[telemetry] collector enabled at POST /api/telemetry/report (+ GET /api/public/stats)');
} }
@ -1542,20 +1519,6 @@ server.listen(listenPort, '0.0.0.0', () => {
`); `);
// Build the BrightSign package now rather than on the first player that asks. It is cached
// per stamped server URL, so this was never per-request work — but the FIRST request otherwise
// pays for a zip build, and that request is a player mid-boot deciding whether to update.
//
// Only possible when APP_URL is set: without it the URL comes from the request's Host, which
// does not exist yet at boot. Those deployments build once, lazily, on first contact.
if (process.env.APP_URL) {
bsPackage.getPackage(bsPackage.packageServerUrl(null))
.then((p) => console.log(p
? `[brightsign] package ${p.version} ready (${p.size} bytes, sha256 ${p.sha256.slice(0, 12)}…) -> ${process.env.APP_URL}`
: '[brightsign] no package available (missing brightsign/ or VERSION) — players keep what they have'))
.catch(() => { /* never let a packaging problem stop the server booting */ });
}
// Email transport diagnostics — a partially-configured transport is a real // Email transport diagnostics — a partially-configured transport is a real
// misconfiguration (some fields set, others missing) and gets a loud line; // misconfiguration (some fields set, others missing) and gets a loud line;
// a fully-unset transport just falls back to the stdout logger silently. // a fully-unset transport just falls back to the stdout logger silently.

View file

@ -234,125 +234,3 @@ test('diagnostics can never take the player down', () => {
assert.equal((fn.match(/try \{/g) || []).length >= 2, true, 'both the wiring and each callback must be guarded'); assert.equal((fn.match(/try \{/g) || []).length >= 2, true, 'both the wiring and each callback must be guarded');
assert.match(fn, /catch \(e\) \{ \/\* diagnostics must never break playback/); assert.match(fn, /catch \(e\) \{ \/\* diagnostics must never break playback/);
}); });
// ---------------------------------------------------------------------------------------------
// The fourth hop: host version -> devices.app_version
//
// package_version reaching telemetrySnapshot() (pinned above) was never the problem. It rode the
// heartbeat for releases and the server dropped it: device_telemetry has no column for it, and
// device_info.app_version was the literal '1.1.0-web' for every web player, BrightSign included.
// An XT245 running a year-old host and one provisioned this morning reported the same string.
//
// Executed rather than pattern-matched wherever it can be. A regex proving the literal is gone
// says nothing about what replaced it, and the failure being repaired here is precisely a value
// that looks plausible and means nothing.
// ---------------------------------------------------------------------------------------------
// Lift a top-level `function name(...)` out of the page by matching braces. Cheap, and it beats
// asserting on source text for functions whose whole contract is what they RETURN.
function playerFn(name) {
const start = player.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name}() must exist in the player`);
let depth = 0;
for (let i = player.indexOf('{', start); i < player.length; i++) {
if (player[i] === '{') depth++;
else if (player[i] === '}' && --depth === 0) return player.slice(start, i + 1);
}
throw new Error(`unbalanced braces reading ${name}()`);
}
// Evaluate hostPackageVersion + buildDeviceInfo against a stub bridge. Everything else the page
// touches is stubbed to a fixed value so a change in THOSE cannot turn this test red.
function deviceInfoWith(BS) {
const sandbox = {
BS,
PLAYER_VERSION: '1.1.0-web',
navigator: { userAgent: 'Mozilla/5.0 Chrome/120' },
screen: { width: 1920, height: 1200 },
};
const vm = require('node:vm');
vm.runInNewContext(
`${playerFn('hostPackageVersion')}\n${playerFn('buildDeviceInfo')}\nresult = buildDeviceInfo();`,
sandbox,
);
return sandbox.result;
}
test('on a BrightSign, app_version is the HOST package version, not the page version', () => {
const di = deviceInfoWith({ telemetrySnapshot: () => ({ package_version: '1.9.36' }) });
assert.equal(di.app_version, '1.9.36');
assert.notEqual(di.app_version, '1.1.0-web', 'reporting the page version here is the bug itself');
});
test('off-platform the reported app_version is unchanged, so no existing device shifts', () => {
// The page ships to Android/Tizen/desktop too. This fix must be invisible to them.
assert.equal(deviceInfoWith(null).app_version, '1.1.0-web');
assert.equal(deviceInfoWith({}).app_version, '1.1.0-web', 'a bridge without the method');
});
test('a CACHED older bridge must not throw registration away', () => {
// The page and st-bridge.js are fetched separately and Cloudflare holds the bridge for hours, so
// a new page routinely runs against an old one. This exact shape once threw every 15s and took
// the whole heartbeat with it while the display kept playing.
const angry = { telemetrySnapshot: () => { throw new Error('older bridge'); } };
assert.equal(deviceInfoWith(angry).app_version, '1.1.0-web', 'must degrade, not throw');
});
test('a host that reports a blank version is treated as no version, not as a blank one', () => {
for (const bad of [undefined, null, '', ' ', 42]) {
const di = deviceInfoWith({ telemetrySnapshot: () => ({ package_version: bad }) });
assert.equal(di.app_version, '1.1.0-web', `${JSON.stringify(bad)} must not land in the column`);
}
});
test('device_info is always the WHOLE blob — a partial one silently wipes the row', () => {
// ⚠️ The server's applyDeviceInfo() UPDATEs every column it covers unconditionally, so a
// partial device_info does not patch: it nulls android_version and the screen dimensions and
// resets ota_status/tier/the flag columns. device:register is guarded against an empty blob
// (Object.keys().length > 0); device:info is NOT, and that is the path this feature added.
const di = deviceInfoWith({ telemetrySnapshot: () => ({ package_version: '1.9.36' }) });
assert.deepEqual(Object.keys(di).sort(),
['android_version', 'app_version', 'screen_height', 'screen_width']);
assert.match(player, /socket\.emit\('device:info', \{ device_id: config\.deviceId, device_info: buildDeviceInfo\(\) \}\)/,
'device:info must send buildDeviceInfo(), never a hand-built subset');
});
test('a version arriving after registration still reaches the server', () => {
// SendHostTelemetry runs on the autorun's own schedule and can land after the page has already
// registered, so register alone would pin '1.1.0-web' until the next reload. The correction is
// driven from the heartbeat, the one loop guaranteed to run whenever the socket is up.
const hb = player.slice(player.indexOf('function startHeartbeat'), player.indexOf('function stopHeartbeat'));
assert.match(hb, /maybeReportAppVersion\(\)/, 'the heartbeat must re-check the host version');
const fn = playerFn('maybeReportAppVersion');
assert.match(fn, /v === reportedAppVersion\) return/, 'and must stay quiet when nothing changed');
});
// ---------------------------------------------------------------------------------------------
// The page's OWN version
//
// PLAYER_VERSION was the literal '1.1.0-web' and nobody bumped it for the entire 1.x line, so every
// web, Tizen and BrightSign panel reported that as client_version — and, until the app_version fix
// above, as app_version too. Both columns carried the same meaningless string.
// ---------------------------------------------------------------------------------------------
test('the page carries the ST_PLAYER_VERSION marker the server stamps on', () => {
assert.match(player, /const PLAYER_VERSION = '[^']*';/,
'the declaration must stay in a shape the serve-time stamp can match');
assert.match(player, /ST_PLAYER_VERSION/,
'the marker is what the stamp is anchored on — losing it silently freezes every reported version');
});
test('the serve-time stamp is anchored on the declaration, not on the old literal', () => {
const server = fs.readFileSync(path.join(ROOT, 'server', 'server.js'), 'utf8');
assert.match(server, /const PLAYER_VERSION = \)'\[\^'\]\*'/,
'the /player route must rewrite the declaration by pattern');
assert.match(server, /ST_PLAYER_VERSION marker not found/,
'a missing marker must be reported, not silently ignored');
});
test('client_version and app_version are no longer the same constant', () => {
// They describe different things on a BrightSign: the page we serve (always current) versus the
// on-device host package (what OTA replaces). Reporting one value for both hid every skew.
assert.match(player, /app_version: hostPackageVersion\(\) \|\| PLAYER_VERSION/);
assert.match(player, /data\.client_version = PLAYER_VERSION/);
});

View file

@ -108,121 +108,3 @@ test('THE DEPLOYMENT BUG: every member of the package is STORED, never deflated'
} }
assert.ok(found > 0, 'no entries found — the walk itself is wrong, not the archive'); assert.ok(found > 0, 'no entries found — the walk itself is wrong, not the archive');
}); });
// ---------------------------------------------------------------------------------------------
// The package points at the server it was FETCHED FROM
//
// A zip pulled from alpha must provision against alpha. Before this, every package carried the
// committed default (prod) regardless of origin, so provisioning a self-hosted or alpha player
// from its own server silently pointed it at screentinker.com — which surfaces as a pairing bug,
// miles from the packaging code that caused it.
//
// The invariant at the top of this file becomes PER ORIGIN: different URL, different bytes,
// different checksum — and the manifest and download routes must derive the same one.
// ---------------------------------------------------------------------------------------------
// Entries are STORED (no compression), so screentinker.json sits verbatim in the archive and can be
// read without a zip library. Matched on the "key": "value" form specifically: autorun.brs also
// mentions server_url, but only as reg.Exists("server_url") / SaveRegistry("server_url", …), which
// this pattern cannot match.
const packagedServerUrl = (buffer) => {
const m = buffer.toString('latin1').match(/"server_url"\s*:\s*"([^"]*)"/);
assert.ok(m, 'screentinker.json should be readable in the stored archive');
return m[1];
};
test('a package fetched from alpha points at alpha, not the committed default', async () => {
pkgLib._reset();
const alpha = await pkgLib.getPackage('https://alpha.screentinker.com');
assert.equal(packagedServerUrl(alpha.buffer), 'https://alpha.screentinker.com');
pkgLib._reset();
const plain = await pkgLib.getPackage();
assert.equal(packagedServerUrl(plain.buffer), 'https://screentinker.com',
'with no URL to stamp, the committed default ships unchanged');
});
test('THE OTA LOOP, per origin: each package hashes its OWN bytes', async () => {
pkgLib._reset();
const a = await pkgLib.getPackage('https://alpha.screentinker.com');
const b = await pkgLib.getPackage('https://screentinker.com');
assert.notEqual(a.sha256, b.sha256, 'different URLs must produce different bytes');
for (const p of [a, b]) {
assert.equal(p.sha256, crypto.createHash('sha256').update(p.buffer).digest('hex'));
assert.equal(p.size, p.buffer.length, 'Content-Length must match the body');
}
});
test('reproducibility survives: the same URL yields byte-identical packages', async () => {
// The whole reason entry timestamps are fixed. Per-origin caching must not reintroduce the
// churn — a player that sees a new checksum every poll re-downloads forever.
pkgLib._reset();
const first = await pkgLib.getPackage('https://alpha.screentinker.com');
pkgLib._reset();
const second = await pkgLib.getPackage('https://alpha.screentinker.com');
assert.equal(first.sha256, second.sha256);
});
test('the URL is not taken on trust — APP_URL wins, and a hostile Host is sanitised', () => {
const req = (host, protocol = 'https') => ({ protocol, get: (h) => (h === 'host' ? host : null) });
const saved = process.env.APP_URL;
try {
process.env.APP_URL = 'https://configured.example/';
assert.equal(pkgLib.packageServerUrl(req('evil.example')), 'https://configured.example',
'a configured APP_URL must win over the request header, and lose its trailing slash');
delete process.env.APP_URL;
assert.equal(pkgLib.packageServerUrl(req('alpha.screentinker.com')),
'https://alpha.screentinker.com', 'otherwise fall back to the host, so self-hosting needs no config');
for (const bad of ['a.example"; rm -rf /', 'a.example/../x', 'a b.example', 'x'.repeat(200),
'http://a.example', 'a.example:notaport']) {
assert.equal(pkgLib.packageServerUrl(req(bad)), null,
`a host that is not a clean hostname[:port] must ship the default, got it from ${bad}`);
}
assert.equal(pkgLib.packageServerUrl(req('a.example:3001', 'http')), 'http://a.example:3001',
'a port and plain http are legitimate for a self-hosted box');
assert.equal(pkgLib.packageServerUrl(req('')), null, 'no host, no stamp — ship the default');
assert.equal(pkgLib.packageServerUrl(null), null);
} finally {
if (saved === undefined) delete process.env.APP_URL; else process.env.APP_URL = saved;
}
});
test('a corrupt config ships as-is rather than shipping corrupt', async () => {
// autorun.brs reads screentinker.json at boot. A package that cannot be parsed there is a player
// that never starts — strictly worse than one pointing at the wrong server.
const pkgSrc = require('node:fs').readFileSync(
require('node:path').join(__dirname, '..', 'lib', 'brightsign-package.js'), 'utf8');
assert.match(pkgSrc, /catch \(e\) \{\s*return source;/,
'stampServerUrl must fall back to the original text on a parse failure');
});
test('the per-origin cache is bounded — the key comes from a request header', async () => {
pkgLib._reset();
for (let i = 0; i < 40; i++) await pkgLib.getPackage(`https://h${i}.example`);
const src = require('node:fs').readFileSync(
require('node:path').join(__dirname, '..', 'lib', 'brightsign-package.js'), 'utf8');
const m = src.match(/MAX_CACHED_PACKAGES\s*=\s*(\d+)/);
assert.ok(m, 'the bound must be a named constant, not a magic number');
assert.ok(Number(m[1]) <= 32, 'a ~73KB buffer per entry keyed on a header needs a small bound');
});
test('BOTH routes derive the stamped URL the same way, or the manifest lies about the bytes', () => {
// The lib tests above prove one buffer hashes to one checksum PER URL. That guarantee is only
// useful if the manifest route and the download route ask for the SAME url — if one stamps and
// the other does not, the player verifies a checksum against bytes it was never sent and retries
// forever. Asserted on the source because both routes live in server.js behind an Express app
// this file does not boot.
const src = require('node:fs').readFileSync(
require('node:path').join(__dirname, '..', 'server.js'), 'utf8');
const total = (src.match(/bsPackage\.getPackage\(/g) || []).length;
const viaHelper = (src.match(/bsPackage\.getPackage\(bsPackage\.packageServerUrl\(/g) || []).length;
assert.equal(viaHelper, total,
`every getPackage call must route through packageServerUrl; ${total - viaHelper} do not`);
// The two REQUEST-driven routes must both use (req). The boot warm-up legitimately passes null —
// there is no request at boot — and it shares the cache key with APP_URL-configured deployments,
// so it warms the very entry those requests will hit.
const fromRequest = (src.match(/bsPackage\.getPackage\(bsPackage\.packageServerUrl\(req\)\)/g) || []).length;
assert.ok(fromRequest >= 2,
`the manifest and download routes must both derive from the request; found ${fromRequest}`);
});

View file

@ -1,238 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const zlib = require('zlib');
const installer = require('../../brightsign/server/bs-payload-install.js');
/*
* THE POINT OF THIS FILE: a payload update must never eat the database.
*
* The installer replaces the server tree wholesale - it deletes each top-level directory before
* moving the new one into place. That is only safe because runtime state (database, uploads,
* certs, .jwt_secret) lives in DATA_DIR, OUTSIDE that tree. On the first player this was not true:
* the launcher computed DATA_DIR for its own display but never exported it, so server/config.js
* fell back to its own __dirname and wrote the database into server/db - inside the tree the
* installer deletes. Nothing failed. The database was simply going to disappear on the first
* update, silently, on a device in someone else's building.
*
* So these tests assert the property directly - update, then check the bytes are still there -
* rather than asserting that some variable is set.
*/
/* --------------------------------------------------------------------------------------------
* A minimal STORED zip writer.
*
* Built here rather than shelling out to `zip` so the test has no external dependency, and because
* hand-writing the format is what lets the malformed cases below exist at all - there is no way to
* ask `zip` for an entry named "../escape.txt".
* ------------------------------------------------------------------------------------------ */
function makeZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const [name, contentRaw] of entries) {
const content = Buffer.from(contentRaw);
const nameBuf = Buffer.from(name, 'utf8');
const crc = zlib.crc32 ? zlib.crc32(content) : 0;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4); // version needed
local.writeUInt16LE(0, 6); // flags
local.writeUInt16LE(0, 8); // method 0 = STORED
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(content.length, 18);
local.writeUInt32LE(content.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28); // extra length
locals.push(local, nameBuf, content);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(0, 10); // method
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(content.length, 20);
central.writeUInt32LE(content.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE(offset, 42);
centrals.push(central, nameBuf);
offset += local.length + nameBuf.length + content.length;
}
const cd = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(cd.length, 12);
eocd.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, cd, eocd]);
}
/* Serve one buffer over real HTTP - install() speaks http, so the test should too. */
async function serve(buf) {
const server = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/zip', 'content-length': buf.length });
res.end(buf);
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { url: `http://127.0.0.1:${server.address().port}/payload.zip`,
close: () => new Promise((r) => server.close(r)) };
}
const PAYLOAD = [
['server/', ''],
['server/server.js', 'module.exports = "v1";\n'],
['server/routes/', ''],
['server/routes/api.js', 'module.exports = 1;\n'],
['frontend/', ''],
['frontend/index.html', '<h1>v1</h1>\n'],
];
function scratch() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'payload-'));
return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
}
test('a fresh install unpacks the tree', async () => {
const { dir, cleanup } = scratch();
const s = await serve(makeZip(PAYLOAD));
try {
process.env.DATA_DIR = path.join(dir, 'data');
const r = await installer.install({ url: s.url, installDir: dir });
assert.strictEqual(fs.existsSync(path.join(dir, 'server', 'server.js')), true);
assert.strictEqual(fs.readFileSync(path.join(dir, 'frontend', 'index.html'), 'utf8'), '<h1>v1</h1>\n');
assert.ok(r.files >= 3);
// The archive is 70-odd MB in production and useless once unpacked.
assert.strictEqual(fs.existsSync(path.join(dir, 'server-payload.zip')), false);
} finally { await s.close(); cleanup(); delete process.env.DATA_DIR; }
});
test('THE POINT: an update leaves the database, uploads and jwt secret untouched', async () => {
const { dir, cleanup } = scratch();
const dataDir = path.join(dir, 'data');
try {
// A device that has been running: state in DATA_DIR, and a previous payload installed.
fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'uploads', 'content'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'certs'), { recursive: true });
const dbBytes = Buffer.from('SQLite format 3\0real customer data');
fs.writeFileSync(path.join(dataDir, 'db', 'remote_display.db'), dbBytes);
fs.writeFileSync(path.join(dataDir, 'uploads', 'content', 'video.mp4'), 'MP4');
fs.writeFileSync(path.join(dataDir, 'certs', '.jwt_secret'), 'per-install-secret');
process.env.DATA_DIR = dataDir;
const first = await serve(makeZip(PAYLOAD));
await installer.install({ url: first.url, installDir: dir });
await first.close();
// Now push an update: different contents, same shape.
const v2 = PAYLOAD.map(([n, c]) => [n, String(c).replace(/v1/g, 'v2')]);
const second = await serve(makeZip(v2));
await installer.install({ url: second.url, installDir: dir });
await second.close();
// The new code is in place...
assert.strictEqual(fs.readFileSync(path.join(dir, 'frontend', 'index.html'), 'utf8'), '<h1>v2</h1>\n');
// ...and every byte of state survived it.
assert.deepStrictEqual(fs.readFileSync(path.join(dataDir, 'db', 'remote_display.db')), dbBytes);
assert.strictEqual(fs.readFileSync(path.join(dataDir, 'uploads', 'content', 'video.mp4'), 'utf8'), 'MP4');
assert.strictEqual(fs.readFileSync(path.join(dataDir, 'certs', '.jwt_secret'), 'utf8'), 'per-install-secret');
} finally { cleanup(); delete process.env.DATA_DIR; }
});
test('refuses to install at all if DATA_DIR sits inside the tree it replaces', async () => {
// The exact misconfiguration that shipped to the first player: state under server/, which the
// installer deletes. Refusing loudly beats deleting a database quietly.
const { dir, cleanup } = scratch();
const s = await serve(makeZip(PAYLOAD));
try {
const dataDir = path.join(dir, 'server');
fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'db', 'remote_display.db'), 'precious');
process.env.DATA_DIR = dataDir;
await assert.rejects(() => installer.install({ url: s.url, installDir: dir }),
/DATA_DIR .* is inside the payload tree/);
// and it did not take the database with it on the way out
assert.strictEqual(fs.readFileSync(path.join(dataDir, 'db', 'remote_display.db'), 'utf8'), 'precious');
} finally { await s.close(); cleanup(); delete process.env.DATA_DIR; }
});
test('a payload missing server/server.js is rejected without touching a working install', async () => {
// A truncated or wrong archive must not be able to destroy a device that is currently working.
const { dir, cleanup } = scratch();
try {
process.env.DATA_DIR = path.join(dir, 'data');
const good = await serve(makeZip(PAYLOAD));
await installer.install({ url: good.url, installDir: dir });
await good.close();
const bad = await serve(makeZip([['frontend/index.html', 'nope']]));
await assert.rejects(() => installer.install({ url: bad.url, installDir: dir }),
/no server\/server\.js/);
await bad.close();
// still the working v1 install
assert.strictEqual(fs.readFileSync(path.join(dir, 'server', 'server.js'), 'utf8'), 'module.exports = "v1";\n');
} finally { cleanup(); delete process.env.DATA_DIR; }
});
test('an entry that escapes the destination is skipped, not written', async () => {
const { dir, cleanup } = scratch();
const outside = path.join(dir, 'escaped.txt');
const s = await serve(makeZip([...PAYLOAD, ['../escaped.txt', 'pwned']]));
try {
process.env.DATA_DIR = path.join(dir, 'data');
const target = path.join(dir, 'install');
fs.mkdirSync(target);
const r = await installer.install({ url: s.url, installDir: target });
assert.strictEqual(fs.existsSync(outside), false);
assert.ok(r.skipped >= 1, 'the escaping entry should be counted as skipped');
} finally { await s.close(); cleanup(); delete process.env.DATA_DIR; }
});
test('a 404 fails cleanly and leaves no half-downloaded file behind', async () => {
const { dir, cleanup } = scratch();
const server = http.createServer((req, res) => { res.writeHead(404); res.end('no'); });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
try {
process.env.DATA_DIR = path.join(dir, 'data');
await assert.rejects(
() => installer.install({ url: `http://127.0.0.1:${server.address().port}/x.zip`, installDir: dir }),
/HTTP 404/);
assert.strictEqual(fs.existsSync(path.join(dir, 'server-payload.zip')), false);
assert.strictEqual(fs.existsSync(path.join(dir, 'server-payload.zip.part')), false);
} finally { await new Promise((r) => server.close(r)); cleanup(); delete process.env.DATA_DIR; }
});
test('a corrupted file is caught by its checksum instead of landing on disk', async () => {
/*
* Without this check a damaged byte reaches the player intact-looking and surfaces later as
* something unrelated - a SyntaxError from a file nobody edited. Flip one byte in the payload
* and the install must refuse rather than commit it.
*/
const { dir, cleanup } = scratch();
const zip = makeZip(PAYLOAD);
const marker = Buffer.from('module.exports = "v1";');
const at = zip.indexOf(marker);
assert.ok(at > 0, 'fixture should contain the entry body');
zip[at] = zip[at] ^ 0xff; // corrupt one byte, leave the CRC claiming otherwise
const s = await serve(zip);
try {
process.env.DATA_DIR = path.join(dir, 'data');
await assert.rejects(() => installer.install({ url: s.url, installDir: dir }), /checksum mismatch/);
assert.strictEqual(fs.existsSync(path.join(dir, 'server', 'server.js')), false);
} finally { await s.close(); cleanup(); delete process.env.DATA_DIR; }
});

View file

@ -1,111 +0,0 @@
'use strict';
// What a BrightSign shows: the server's own diagnostics, or the player.
//
// The box is both server and player, so the screen has to be one or the other at any moment, and
// the interesting part is the transitions:
//
// - a fresh install has nothing to play and nobody to play it for, so it must show the address
// where the first account gets created. There is no keyboard on a player; hiding that address
// leaves the device unsetuppable.
// - once an account exists it should get out of the way and be a screen.
// - if the server later fails, the diagnostics must come BACK, or a black display is the only
// symptom of a server that died overnight.
//
// That last requirement is why the player is an iframe layer rather than a navigation: navigating
// would replace the document and kill the poller that notices the failure.
//
// The decision lives in node-server.html, which ships in autorun.zip and cannot be imported. It is
// written as one pure function so the table below can pin it; the test lifts that function out of
// the page rather than restating it, so a change to the page is a change to what is tested.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const PAGE = fs.readFileSync(
path.join(__dirname, '..', '..', 'brightsign', 'server', 'node-server.html'), 'utf8');
function loadScreenState() {
const at = PAGE.indexOf('function screenState(');
assert.notEqual(at, -1, 'screenState() not found in node-server.html');
const open = PAGE.indexOf('{', at);
let depth = 0, end = -1;
for (let i = open; i < PAGE.length; i++) {
if (PAGE[i] === '{') depth++;
else if (PAGE[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
assert.notEqual(end, -1, 'unbalanced screenState()');
// eslint-disable-next-line no-new-func
return new Function(`${PAGE.slice(at, end + 1)}; return screenState;`)();
}
const screenState = loadScreenState();
const frame = (over) => Object.assign(
{ serving: true, fatal: null, needsSetup: false, port: '8181' }, over);
test('a healthy server with an account shows the player', () => {
assert.equal(screenState(frame(), true), 'player');
});
test('a player that was never asked to be a server says so, rather than reporting a fault', () => {
// st-config.json is absent or {"server": 0} - the default. There is no status listener to poll,
// so without this the page would show "no answer from the server process" and send someone
// looking for a broken server that was never meant to exist.
assert.equal(screenState(null, false), 'disabled');
assert.equal(screenState(frame(), false), 'disabled', 'the setting wins over any stale status');
});
test('a healthy server with NO account shows setup, not a blank player', () => {
// The whole point of requirement 1: stay on the config screen until someone has signed up.
assert.equal(screenState(frame({ needsSetup: true }), true), 'setup');
});
test('an unanswered setup probe is not treated as "no setup needed"', () => {
// null means we have not been told yet. Guessing "false" here would flip a fresh box to a player
// that has nothing to show, and take the sign-up address off the screen while doing it.
assert.equal(screenState(frame({ needsSetup: null }), true), 'diagnostics');
assert.equal(screenState(frame({ needsSetup: undefined }), true), 'diagnostics');
});
test('nothing listening means diagnostics, whatever else is true', () => {
assert.equal(screenState(frame({ serving: false }), true), 'diagnostics');
assert.equal(screenState(frame({ serving: false, needsSetup: false }), true), 'diagnostics');
});
test('THE RECOVERY CASE: a server that fails takes the player off the screen', () => {
// Requirement 2. A box that has been playing for weeks and then throws must show the operator
// something other than black.
const playing = frame();
assert.equal(screenState(playing, true), 'player');
const broken = frame({ fatal: 'server failed to start TypeError: ...' });
assert.equal(screenState(broken, true), 'diagnostics', 'a fatal must reveal the diagnostics again');
const gone = frame({ serving: false });
assert.equal(screenState(gone, true), 'diagnostics', 'so must the port going away');
});
test('no status at all is diagnostics rather than a crash', () => {
// The first paint happens before the first poll answers.
assert.equal(screenState(null, true), 'diagnostics');
assert.equal(screenState(undefined, true), 'diagnostics');
});
test('the page reloads the player when it comes back, rather than leaving an error page', () => {
// Not expressible in the pure function - assert the wiring instead. A player that rendered a
// connection error while the server was down will sit on it forever unless the src is re-set.
assert.match(PAGE, /if \(!playerShown\)[\s\S]{0,220}frame\.src =/,
'entering the player state must (re)assign the iframe src');
assert.match(PAGE, /frame\.removeAttribute\('src'\)/,
'leaving it must blank the frame so a dead server is not hammered behind an invisible layer');
});
test('the player is a layer, never a navigation', () => {
// location.href = player would replace this document and kill the poller that implements
// requirement 2. This is the assertion that stops someone "simplifying" it back.
assert.doesNotMatch(PAGE, /location\.href\s*=/,
'navigating away would destroy the only thing able to notice the server failing');
});

View file

@ -368,135 +368,3 @@ test('a player that cannot read its output grows no empty rows', () => {
assert.equal(has(html, 'telDisplay'), false); assert.equal(has(html, 'telDisplay'), false);
assert.equal(has(html, 'telVideoMode'), false); assert.equal(has(html, 'telVideoMode'), false);
}); });
// ---------------------------------------------------------------------------------------------
// The System View pad and `tier`
//
// `tier` is an ANDROID device-owner concept — NOT NULL DEFAULT 0 in db/database.js, written only
// from the APK's DeviceInfo. A BrightSign, Tizen or web player never sends it, so it sits at the
// column default forever and can never reach 2. The pad was gated on `tier === 2` alone, which
// meant HOME / BACK / POWER / the D-pad / OK rendered click-blocked on every non-Android display
// — for keys those players genuinely handle (server/player/index.html:1895-1938,
// tizen/js/app.js:435-444). That is the "button that cannot work" this whole file argues against,
// inverted: a button that DOES work, presented as if it does not.
// ---------------------------------------------------------------------------------------------
// The pad is one div; read the inline style off it rather than asserting on the whole document.
// Reads FORWARD from the id — the style attribute follows it on the same tag. An earlier version
// searched backwards and picked up the preceding <hr>'s style, which made three of these tests
// pass without ever looking at the pad.
const padStyle = (html) => {
const i = html.indexOf('id="systemViewControls"');
if (i === -1) return null;
const s = html.indexOf('style="', i);
const end = html.indexOf('>', i);
if (s === -1 || s > end) return ''; // the tag carries no style at all
return html.slice(s + 7, html.indexOf('"', s + 7));
};
test('the system view pad is live on a BrightSign, which has no tier to earn', () => {
const style = padStyle(render(BRIGHTSIGN));
assert.ok(style, 'the pad must still render — these keys work on a BrightSign');
assert.ok(!style.includes('pointer-events:none'), `pad was click-blocked: ${style}`);
assert.ok(!style.includes('opacity:0.4'), `pad was greyed: ${style}`);
});
test('and on Tizen, for the same reason', () => {
const style = padStyle(render(TIZEN));
assert.ok(style && !style.includes('pointer-events:none'), `pad was click-blocked: ${style}`);
});
test('but an Android device that has NOT earned device-owner is still locked', () => {
// The #161 gate is real on Android: without device-owner these keycodes need the accessibility
// path, and offering them unlocked would be the original sin in the other direction.
const style = padStyle(render({ ...ANDROID_FULL, tier: 0 }));
assert.ok(style.includes('pointer-events:none'), `tier-0 Android must stay locked: ${style}`);
assert.ok(style.includes('opacity:0.4'), `tier-0 Android must stay greyed: ${style}`);
});
test('and an Android device owner is unlocked', () => {
const style = padStyle(render({ ...ANDROID_FULL, tier: 2 }));
assert.ok(!style.includes('pointer-events:none'), `tier-2 Android must be live: ${style}`);
});
test('the two genuinely Android-only keys are not offered elsewhere', () => {
// KEYCODE_APP_SWITCH has a case only in the APK (WebSocketService.kt:1068). 'settings' has no
// handler outside Android at all and is not even in COMMAND_CAPABILITY, so the server forwards
// it and a non-Android player silently drops it — a button that reports success and does
// nothing, which is worse than an absent one.
for (const [name, dev] of [['brightsign', BRIGHTSIGN], ['tizen', TIZEN], ['web', WEB]]) {
const html = render(dev);
assert.ok(!html.includes('KEYCODE_APP_SWITCH'), `${name} must not offer Recents`);
assert.ok(!html.includes("_sendCmd('settings')"), `${name} must not offer Settings`);
}
const android = render({ ...ANDROID_FULL, tier: 2 });
assert.ok(android.includes('KEYCODE_APP_SWITCH'), 'Android keeps Recents');
assert.ok(android.includes("_sendCmd('settings')"), 'Android keeps Settings');
});
test('the keys that DO work off Android are still rendered everywhere', () => {
// The failure this guards against is an over-eager cleanup that deletes the whole pad off
// Android, taking five working controls with it.
for (const [name, dev] of [['brightsign', BRIGHTSIGN], ['tizen', TIZEN], ['web', WEB]]) {
const html = render(dev);
for (const key of ['KEYCODE_HOME', 'KEYCODE_BACK', 'KEYCODE_POWER', 'KEYCODE_DPAD_CENTER']) {
assert.ok(html.includes(key), `${name} must keep ${key} — the player handles it`);
}
}
});
// ---------------------------------------------------------------------------------------------
// Player version on the Info tab
//
// The version card lived inside the block gated on
// device.android_version && !device.android_version.startsWith('Web/')
// so it rendered for the APK only. A BrightSign, Tizen or web player registers android_version as
// "Web/<ua>", which fails that test — so those panels showed no version anywhere in the UI, and an
// operator had no way to tell a freshly-provisioned host from a year-old one.
// ---------------------------------------------------------------------------------------------
const infoCard = (html, label) => {
const i = html.indexOf(label);
if (i === -1) return null;
const v = html.indexOf('info-card-value', i);
return v === -1 ? null : html.slice(v, html.indexOf('</div>', v));
};
test('a BrightSign shows its player version on the Info tab', () => {
const html = render({ ...BRIGHTSIGN, app_version: '1.9.36', client_version: '1.1.0-web' });
const card = infoCard(html, 'device.info.app_version');
assert.ok(card, 'the version card must render off Android');
assert.ok(card.includes('1.9.36'), `expected the host package version, got: ${card}`);
});
test('and the page version alongside it, because the two can disagree', () => {
// On a BrightSign app_version is the on-device host package and client_version is the page we
// serve. A stale host against a fresh page is exactly the skew worth seeing at a glance.
const html = render({ ...BRIGHTSIGN, app_version: '1.9.36', client_version: '1.1.0-web' });
const i = html.indexOf('device.info.app_version');
assert.ok(html.slice(i, i + 400).includes('1.1.0-web'), 'the page version should appear too');
// When they match there is nothing to disambiguate, so it must not be repeated.
const same = render({ ...BRIGHTSIGN, app_version: '1.9.36', client_version: '1.9.36' });
const j = same.indexOf('device.info.app_version');
const seg = same.slice(j, j + 400);
assert.equal((seg.match(/1\.9\.36/g) || []).length, 1, 'identical versions must not be shown twice');
});
test('Tizen and web players get it too, and Android is unchanged', () => {
for (const [name, dev] of [['tizen', TIZEN], ['web', WEB], ['android', ANDROID_FULL]]) {
const html = render({ ...dev, app_version: '9.9.9' });
const card = infoCard(html, 'device.info.app_version');
assert.ok(card && card.includes('9.9.9'), `${name} must show a player version`);
}
});
test('the Android-only cards stay Android-only', () => {
// Moving the version card out must not drag the APK-specific ones with it: a settings PIN and an
// Android OS version mean nothing on a BrightSign.
const bs = render({ ...BRIGHTSIGN, app_version: '1.9.36' });
assert.ok(!bs.includes('device.info.settings_pin'), 'settings PIN is an APK concept');
assert.ok(!bs.includes('device.info.android_version'), 'android_version is an APK concept');
const android = render({ ...ANDROID_FULL, app_version: '1.9.36' });
assert.ok(android.includes('device.info.settings_pin'), 'Android keeps its PIN card');
});

View file

@ -1,222 +0,0 @@
'use strict';
/*
* The EDID parser, checked against a REAL panel.
*
* The fixture below is assembled byte by byte to match what the XT245's own DWS reported for the
* CX101 attached to it manufacturer RTK, product 0x1010, serial 1, made 2020 week 26, 22x13 cm,
* gamma 2.20, and a preferred mode whose modeline is
*
* "1920x1200x62p 168.50 1920 2008 2052 2200 1200 1204 1209 1245"
*
* That last one matters: the DWS calls it 62p, which looks like a typo for 60 until you divide the
* pixel clock by the totals 168.5MHz / (2200 x 1245) = 61.5Hz. A parser that "helpfully" rounds
* to 60 would disagree with the player's own diagnostics about the panel in front of it, which is
* the one thing an operator would use this screen to check.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { parseEdid } = require('../lib/edid');
function buildCx101() {
const b = Buffer.alloc(128);
Buffer.from([0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]).copy(b, 0);
// 'RTK' — five bits per letter, big-endian, A=1.
b.writeUInt16BE(((18 & 0x1f) << 10) | ((20 & 0x1f) << 5) | (11 & 0x1f), 8);
b.writeUInt16LE(0x1010, 10); // product
b.writeUInt32LE(1, 12); // serial
b[16] = 26; // week
b[17] = 2020 - 1990; // year
b[18] = 1; b[19] = 3; // EDID 1.3
b[20] = 0x80; // digital
b[21] = 22; b[22] = 13; // cm
b[23] = 220 - 100; // gamma 2.20
// Established: 640x480@60 (0x20) + 800x600@60 (0x01) in byte 35, 1024x768@60 (0x08) in byte 36.
b[35] = 0x20 | 0x01;
b[36] = 0x08;
// Standard timings. 1920x1200@60 = 16:10, and 1280x720@60 = 16:9.
b[38] = 1920 / 8 - 31; b[39] = (0 << 6) | (60 - 60);
b[40] = 1280 / 8 - 31; b[41] = (3 << 6) | (60 - 60);
for (let i = 42; i <= 52; i += 2) { b[i] = 0x01; b[i + 1] = 0x01; }
// DTD 1 — the preferred mode, from the modeline above.
const d = b.slice(54, 72);
d.writeUInt16LE(16850, 0); // 168.50 MHz in 10kHz units
const hActive = 1920, hBlank = 2200 - 1920, vActive = 1200, vBlank = 1245 - 1200;
d[2] = hActive & 0xff; d[3] = hBlank & 0xff;
d[4] = ((hActive >> 8) << 4) | (hBlank >> 8);
d[5] = vActive & 0xff; d[6] = vBlank & 0xff;
d[7] = ((vActive >> 8) << 4) | (vBlank >> 8);
d[12] = 476 & 0xff; d[13] = 268 & 0xff;
d[14] = ((476 >> 8) << 4) | (268 >> 8);
d[17] = 0x1e; // digital separate, +h +v
// Descriptor 2 — monitor name.
b[72] = 0; b[73] = 0; b[74] = 0; b[75] = 0xfc; b[76] = 0;
Buffer.from('CX101\n').copy(b, 77);
for (let i = 77 + 6; i < 90; i++) b[i] = 0x20;
b[126] = 0; // no extension blocks
b[127] = (256 - (b.slice(0, 127).reduce((a, x) => (a + x) & 0xff, 0) % 256)) & 0xff;
return b;
}
const CX101 = buildCx101();
test('the identity fields match what the player DWS reports for this panel', () => {
const e = parseEdid(CX101);
assert.ok(e, 'a well-formed EDID must parse');
assert.equal(e.manufacturer, 'RTK');
assert.equal(e.productHex, '0x1010');
assert.equal(e.serialNumber, 1);
assert.equal(e.weekOfManufacture, 26);
assert.equal(e.yearOfManufacture, 2020);
assert.equal(e.edidVersion, '1.3');
assert.equal(e.digital, true);
assert.equal(e.widthCm, 22);
assert.equal(e.heightCm, 13);
assert.equal(e.gamma, 2.2);
assert.equal(e.monitorName, 'CX101');
assert.equal(e.checksumValid, true);
});
test('the preferred mode is 62p, exactly as the DWS modeline computes', () => {
// 168.5MHz / (2200 x 1245) = 61.5Hz. Rounding to a "nicer" 60 would contradict the player.
const e = parseEdid(CX101);
assert.equal(e.preferredMode, '1920x1200@62');
const dtd = e.detailedTimings[0];
assert.equal(dtd.pixelClockKhz, 168500);
assert.equal(dtd.width, 1920);
assert.equal(dtd.height, 1200);
assert.equal(dtd.interlaced, false);
});
test('established and standard timing lists come back', () => {
const e = parseEdid(CX101);
for (const m of ['640x480@60', '800x600@60', '1024x768@60']) {
assert.ok(e.establishedTimings.includes(m), `expected ${m} in ${e.establishedTimings}`);
}
const labels = e.standardTimings.map((s) => s.label);
assert.deepEqual(labels, ['1920x1200@60', '1280x720@60'],
'unused 0x01 0x01 slots must be skipped, not reported as modes');
});
test('a bad panel degrades to "we do not know", never to a thrown page', () => {
// This runs on bytes a display supplied. The device page must survive a monitor that lies.
assert.equal(parseEdid(null), null);
assert.equal(parseEdid(Buffer.alloc(0)), null);
assert.equal(parseEdid(Buffer.alloc(128)), null, 'all zeroes has no EDID header');
assert.equal(parseEdid(Buffer.alloc(64, 0xff)), null, 'too short to be a base block');
assert.equal(parseEdid('not an edid at all'), null);
});
test('a corrupt checksum is REPORTED, not rejected', () => {
// A panel with a bad checksum still answers most questions correctly, and an installer chasing a
// flaky cable wants to see the fields AND be told the block is suspect. Dropping it wholesale
// would hide the very evidence they need.
const bad = Buffer.from(CX101);
bad[127] = (bad[127] + 1) & 0xff;
const e = parseEdid(bad);
assert.ok(e, 'a bad checksum must still parse');
assert.equal(e.checksumValid, false);
assert.equal(e.monitorName, 'CX101');
});
test('the wire formats the bridge might send all land in the same place', () => {
// getEdid() has not been observed on hardware yet, so accept the plausible shapes rather than
// betting on one: a Buffer, a byte array, a Uint8Array, base64, or hex.
const expected = parseEdid(CX101);
const shapes = {
array: Array.from(CX101),
uint8: new Uint8Array(CX101),
base64: CX101.toString('base64'),
hex: CX101.toString('hex'),
};
for (const [name, value] of Object.entries(shapes)) {
const got = parseEdid(value);
assert.ok(got, `${name} should parse`);
assert.equal(got.monitorName, expected.monitorName, `${name} lost the monitor name`);
assert.equal(got.productHex, expected.productHex, `${name} lost the product id`);
}
});
test('a CEA extension contributes the colorimetry flags the DWS shows', () => {
// "BT2020 RGB supported / BT2020 YCbCr supported" comes from the CEA colorimetry data block, not
// the base block — which is why getEdidIdentity()'s flags and the raw bytes must agree.
const ext = Buffer.alloc(128);
ext[0] = 0x02; ext[1] = 3; ext[2] = 8; ext[3] = 0x00;
ext[4] = (7 << 5) | 3; // extended tag, length 3
ext[5] = 0x05; // colorimetry data block
ext[6] = 0x80 | 0x40; // BT2020 RGB + YCC
ext[7] = 0x00;
const two = Buffer.concat([Buffer.from(CX101), ext]);
two[126] = 1;
two[127] = (256 - (two.slice(0, 127).reduce((a, x) => (a + x) & 0xff, 0) % 256)) & 0xff;
const e = parseEdid(two);
assert.equal(e.extensionBlocks, 1);
assert.equal(e.cea.bt2020Rgb, true);
assert.equal(e.cea.bt2020Ycc, true);
});
// ---------------------------------------------------------------------------------------------
// The path from panel to page
//
// Four hops, none of which can be executed here: the bridge reads getEdid() on a widget, the page
// sends it on register, applyHardwareIdentity stores it, the device route parses it on read. Each
// is pinned against its own source, because a break anywhere is silent — the card simply does not
// appear, which looks exactly like a panel that never reported an EDID.
// ---------------------------------------------------------------------------------------------
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
test('the bridge collects the RAW block, not just the identity object', () => {
const bridge = read('brightsign', 'st-bridge.js');
assert.match(bridge, /typeof vo\.getEdid === 'function'/,
'getEdidIdentity() cannot answer manufacturer, gamma or the mode lists — the raw block must be read');
assert.match(bridge, /edid: function \(\) \{ return edidRaw; \}/, 'and exposed to the page');
assert.match(bridge, /function toBase64/, 'normalised, because the return shape is undocumented');
});
test('EDID rides the REGISTER, not the heartbeat', () => {
// It changes when someone swaps the screen. ~350 characters of unchanging base64 every 15
// seconds, forever, across a fleet, to say the same thing each time.
const player = read('server', 'player', 'index.html');
assert.match(player, /data\.bs_edid = BS\.edid\(\) \|\| null/);
const hb = player.slice(player.indexOf('function startHeartbeat'), player.indexOf('function stopHeartbeat'));
assert.ok(!hb.includes('bs_edid'), 'the heartbeat must not carry it');
assert.match(player, /maybeReportEdid/, 'but a late-arriving probe must still be reported');
});
test('a device that reports no EDID does not erase the one already stored', () => {
// The probe is async and the first register usually predates it, so nulls are NORMAL. A plain
// assignment would blank the column on every reconnect and the card would flicker in and out.
const sock = read('server', 'ws', 'deviceSocket.js');
assert.match(sock, /hardware_edid\s*=\s*COALESCE\(\?, hardware_edid\)/);
});
test('the blob is stored raw and parsed on READ', () => {
// The whole argument for server-side parsing: a new field is a server deploy, not a fleet
// re-collection. Storing a parsed snapshot instead would freeze today's field list into the DB.
const route = read('server', 'routes', 'devices.js');
assert.match(route, /parseEdid\(device\.hardware_edid\)/);
assert.match(route, /capabilities, edid,/, 'and shipped to the dashboard');
const db = read('server', 'db', 'database.js');
assert.match(db, /ADD COLUMN hardware_edid TEXT/, 'the migration must exist');
});
test('the card renders only when there is something to show', () => {
const view = read('frontend', 'js', 'views', 'device-detail.js');
assert.match(view, /\$\{device\.edid \? `/, 'no EDID must mean no card, not an empty one');
for (const k of ['device.info.edid', 'device.info.edid_preferred', 'device.info.edid_made']) {
assert.ok(view.includes(k), `${k} must be rendered`);
assert.ok(read('frontend', 'js', 'i18n', 'en.js').includes(`'${k}'`), `${k} must be defined in en.js`);
}
});

View file

@ -1,115 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { copyFileBytes } = require('../lib/fsutil');
/*
* These guard the copy used for pre-migration database snapshots. WHY it is not fs.copyFileSync is
* exFAT, and that reasoning lives in lib/fsutil.js. What these check is the part that could still
* go wrong once the reason is accepted: that the replacement is a faithful copy, including across
* the 1MB chunk boundary its loop uses. A snapshot that is silently truncated is worse than no
* snapshot, because the migration proceeds believing it has a backup.
*/
function tmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'fsutil-')); }
test('copies a file byte-for-byte, including non-ASCII bytes', () => {
const dir = tmp();
const src = path.join(dir, 'a.bin');
const dest = path.join(dir, 'b.bin');
const data = Buffer.from([0x00, 0xff, 0x7f, 0x80, 0x41, 0x0a, 0xc3, 0xbf]);
fs.writeFileSync(src, data);
assert.strictEqual(copyFileBytes(src, dest), data.length);
assert.deepStrictEqual(fs.readFileSync(dest), data);
fs.rmSync(dir, { recursive: true, force: true });
});
test('copies a file larger than one chunk', () => {
const dir = tmp();
const src = path.join(dir, 'big.bin');
const dest = path.join(dir, 'big-copy.bin');
// 2.5MB: two full 1MB reads plus a partial one, so an off-by-one in the loop shows up here
// rather than on a player, at boot, in a database backup.
const data = Buffer.alloc(2621440);
for (let i = 0; i < data.length; i++) data[i] = i % 251;
fs.writeFileSync(src, data);
assert.strictEqual(copyFileBytes(src, dest), data.length);
assert.strictEqual(fs.readFileSync(dest).equals(data), true);
fs.rmSync(dir, { recursive: true, force: true });
});
test('overwrites an existing destination completely', () => {
// 'w' truncates. If it did not, copying a short file over a longer one would leave a tail of the
// old contents behind - and the old contents here would be a previous database snapshot.
const dir = tmp();
const src = path.join(dir, 'short.bin');
const dest = path.join(dir, 'long.bin');
fs.writeFileSync(src, 'short');
fs.writeFileSync(dest, 'a very much longer previous file');
copyFileBytes(src, dest);
assert.strictEqual(fs.readFileSync(dest, 'utf8'), 'short');
fs.rmSync(dir, { recursive: true, force: true });
});
test('an empty file copies as empty rather than failing', () => {
const dir = tmp();
const src = path.join(dir, 'empty.bin');
const dest = path.join(dir, 'empty-copy.bin');
fs.writeFileSync(src, '');
assert.strictEqual(copyFileBytes(src, dest), 0);
assert.strictEqual(fs.statSync(dest).size, 0);
fs.rmSync(dir, { recursive: true, force: true });
});
test('a missing source throws rather than leaving an empty destination behind', () => {
// The caller treats a thrown error as "do not migrate". Creating the destination first and then
// failing would leave a zero-byte file that looks like a snapshot.
const dir = tmp();
const dest = path.join(dir, 'out.bin');
assert.throws(() => copyFileBytes(path.join(dir, 'nope.bin'), dest));
assert.strictEqual(fs.existsSync(dest), false);
fs.rmSync(dir, { recursive: true, force: true });
});
test('the copy carries the source permissions across', () => {
// ⚠️ REGRESSION GUARD. The first version of copyFileBytes dropped the chmod entirely, because
// chmod is exactly what made fs.copyFileSync fail on exFAT. The copy then landed at the default
// 0666 & ~umask: a 0600 database snapshot came out 0664, making the whole database group- and
// world-readable on every install. Removing a permission check to fix a permission error is not
// a fix.
const dir = tmp();
const src = path.join(dir, 'db.sqlite');
const dest = path.join(dir, 'snapshot.db');
fs.writeFileSync(src, 'SQLite format 3\0payload');
fs.chmodSync(src, 0o600);
copyFileBytes(src, dest);
const mode = (p) => fs.statSync(p).mode & 0o777;
assert.strictEqual(mode(dest), 0o600,
`snapshot should be 0600 like its source, was 0${mode(dest).toString(8)}`);
fs.rmSync(dir, { recursive: true, force: true });
});
test('a filesystem that refuses chmod still gets its bytes', () => {
// The exFAT case, which is the whole reason this function exists rather than fs.copyFileSync.
// The bytes are written before the mode is attempted, so a refusal must not fail the copy.
const dir = tmp();
const src = path.join(dir, 'a.bin');
const dest = path.join(dir, 'b.bin');
const data = Buffer.from('bytes that must survive a chmod refusal');
fs.writeFileSync(src, data);
const realFchmod = fs.fchmodSync;
fs.fchmodSync = () => { const e = new Error('EPERM: operation not permitted, fchmod'); e.code = 'EPERM'; throw e; };
try {
assert.doesNotThrow(() => copyFileBytes(src, dest), 'a refused chmod must not fail the copy');
assert.deepStrictEqual(fs.readFileSync(dest), data);
} finally {
fs.fchmodSync = realFchmod;
}
fs.rmSync(dir, { recursive: true, force: true });
});

View file

@ -102,45 +102,6 @@ test('every help tip is translated in every active locale', () => {
`these tips fall back to English:\n ${missing.join('\n ')}`); `these tips fall back to English:\n ${missing.join('\n ')}`);
}); });
// Every locale shipped in frontend/js/i18n. Keep in step with the registry in i18n.js.
const ACTIVE_LOCALES = ['es', 'fr', 'de', 'pt', 'hi', 'it', 'ja'];
test('a locale never defines a key that English does not', () => {
// The half of parity that is ALWAYS actionable: a key in a locale file that no longer exists in
// en.js is dead weight or a typo left behind by a rename, and whoever touched that file can fix
// it without speaking the language. Missing keys are the other direction - see below.
for (const locale of ACTIVE_LOCALES) {
const src = fs.readFileSync(path.join(FRONTEND, 'i18n', `${locale}.js`), 'utf8');
const keys = new Set([...src.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1]));
assert.deepEqual([...keys].filter(k => !defined.has(k)), [],
`${locale}.js defines keys that do not exist in en.js`);
}
});
test('translation coverage is reported, but an untranslated string is not a build failure', () => {
// ⚠️ WHY A MISSING TRANSLATION DOES NOT FAIL THE BUILD.
//
// i18n.js lookup() is `registry[lang]?.[key] ?? fallback[key] ?? key`, so an untranslated string
// already renders in English. Nothing is broken by a gap.
//
// Making the gap fatal - as the first version of the Japanese check did - means every new English
// string blocks CI until someone who reads that language is available. That is a guarantee we
// cannot keep, and it puts the cost on whoever is shipping the feature rather than on whoever can
// actually translate. It also singled out one locale: es, fr, de, pt, hi and it were never held
// to it.
//
// So: report the number, do not gate on it. The strings that genuinely must exist everywhere are
// the help tips, and they have their own test above, which does fail.
for (const locale of ACTIVE_LOCALES) {
const src = fs.readFileSync(path.join(FRONTEND, 'i18n', `${locale}.js`), 'utf8');
const keys = new Set([...src.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1]));
const missing = [...defined].filter(k => !keys.has(k));
const pct = ((defined.size - missing.length) / defined.size * 100).toFixed(1);
console.log(` ${locale}: ${defined.size - missing.length}/${defined.size} (${pct}%)` +
(missing.length ? ` - ${missing.length} fall back to English` : ''));
}
});
test('a tip marker in a view always names a real string', () => { test('a tip marker in a view always names a real string', () => {
// <span class="help-tip" data-tip="${t('x')}"> renders the KEY when x is undefined, putting // <span class="help-tip" data-tip="${t('x')}"> renders the KEY when x is undefined, putting
// a bare identifier in the tooltip of the thing meant to explain the page. // a bare identifier in the tooltip of the thing meant to explain the page.

View file

@ -1,90 +0,0 @@
'use strict';
// THE BUG: on a fresh install with no users, typing an email address made the password field
// disappear.
//
// The login form is identifier-first for normal sign-in: it asks the server which identity provider
// an address uses before offering a credential, so an SSO-only user is never shown a password box
// that will be refused. First-run setup set `identified = true` up front so both fields were
// available - 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 first keystroke, set it
// back to false, and hid the password field mid-typing. The same re-render relabelled the button
// from "Create admin account" to "Next".
//
// The decision now lives in frontend/js/lib/login-form-state.js as a pure function so the whole
// truth table can be pinned, including the state the old code could not represent: setup mode where
// `identified` has been clobbered.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const MOD = pathToFileURL(
path.join(__dirname, '..', '..', 'frontend', 'js', 'lib', 'login-form-state.js')).href;
let loginFormState;
test('load the module', async () => {
({ loginFormState } = await import(MOD));
assert.equal(typeof loginFormState, 'function');
});
test('THE BUG: during setup the password survives a keystroke in the email box', async () => {
({ loginFormState } = await import(MOD));
// identified:false is exactly what the input listener used to leave behind. Setup must not care.
const s = loginFormState({ isSetup: true, identified: false, ssoOnlyDomain: false });
assert.equal(s.showPassword, true, 'the password field must stay visible during first-run setup');
assert.equal(s.buttonKey, 'auth.create_admin_account', 'and the button must not become Next');
});
test('setup shows both fields regardless of any other flag', async () => {
({ loginFormState } = await import(MOD));
for (const identified of [true, false]) {
for (const ssoOnlyDomain of [true, false]) {
const s = loginFormState({ isSetup: true, identified, ssoOnlyDomain });
assert.equal(s.showPassword, true,
`setup must show the password (identified=${identified} ssoOnly=${ssoOnlyDomain})`);
assert.equal(s.showButton, true, 'and must always offer the button');
assert.equal(s.buttonKey, 'auth.create_admin_account');
}
}
});
test('normal sign-in still hides the password until an address is submitted', async () => {
({ loginFormState } = await import(MOD));
const before = loginFormState({ isSetup: false, identified: false, ssoOnlyDomain: false });
assert.equal(before.showPassword, false, 'identifier-first: no password box yet');
assert.equal(before.buttonKey, 'auth.next');
const after = loginFormState({ isSetup: false, identified: true, ssoOnlyDomain: false });
assert.equal(after.showPassword, true);
assert.equal(after.buttonKey, 'auth.sign_in');
});
test('an SSO-only domain gets neither a password box nor a submit button', async () => {
({ loginFormState } = await import(MOD));
// The provider button is the only way in; offering a password that will be refused, or a submit
// that cannot work, is worse than offering nothing.
const s = loginFormState({ isSetup: false, identified: true, ssoOnlyDomain: true });
assert.equal(s.showPassword, false);
assert.equal(s.showButton, false);
});
test('the button key is always a real translation key', async () => {
({ loginFormState } = await import(MOD));
// t() renders the KEY when it is undefined, so a typo here puts a bare identifier on the button.
const fs = require('node:fs');
const en = fs.readFileSync(
path.join(__dirname, '..', '..', 'frontend', 'js', 'i18n', 'en.js'), 'utf8');
const seen = new Set();
for (const isSetup of [true, false]) {
for (const identified of [true, false]) {
for (const ssoOnlyDomain of [true, false]) {
seen.add(loginFormState({ isSetup, identified, ssoOnlyDomain }).buttonKey);
}
}
}
for (const key of seen) {
assert.ok(en.includes(`'${key}'`), `${key} is not defined in en.js`);
}
});

View file

@ -17,23 +17,16 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const LOGIN = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'login.js'), 'utf8'); const LOGIN = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'login.js'), 'utf8');
// The visibility decision itself now lives in a pure module - see login-form-state.test.js for its
// truth table. It moved because computing it inline from two mutable flags let a keystroke undo
// first-run setup. These assertions follow it there rather than pinning it to its old address.
const STATE = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'lib', 'login-form-state.js'), 'utf8');
test('password visibility depends on BOTH identification and SSO-only', () => { test('password visibility depends on BOTH identification and SSO-only', () => {
assert.match(STATE, /identified && !ssoOnlyDomain/, assert.match(LOGIN, /const showPassword = identified && !ssoOnlyDomain;/,
'the two drivers must be combined in one place so they cannot disagree'); 'the two drivers must be combined in one place so they cannot disagree');
assert.match(LOGIN, /loginFormState\(\{ isSetup, identified, ssoOnlyDomain \}\)/,
'the view must take its state from that one place rather than recomputing it');
}); });
test('the primary button advances before it signs in', () => { test('the primary button advances before it signs in', () => {
assert.match(LOGIN, /if \(identified && !ssoOnlyDomain\) return doLogin\(\);\s*\n\s*identify\(\);/, assert.match(LOGIN, /if \(identified && !ssoOnlyDomain\) return doLogin\(\);\s*\n\s*identify\(\);/,
'the button must identify first and only sign in once an address is known'); 'the button must identify first and only sign in once an address is known');
assert.match(STATE, /'auth\.sign_in'[\s\S]{0,80}'auth\.next'/, assert.match(LOGIN, /btn\.textContent = identified && !ssoOnlyDomain \? t\('auth\.sign_in'\) : t\('auth\.next'\)/);
'the label must still advance through Next before offering Sign in');
}); });
test('editing the address returns to the identifier step', () => { test('editing the address returns to the identifier step', () => {

View file

@ -24,7 +24,7 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-cal-'));
process.env.DATA_DIR = tmp; process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-calendar'; process.env.JWT_SECRET = 'test-secret-calendar';
const { expandSchedule, parseCalendarDate } = require('../routes/schedules'); const { expandSchedule } = require('../routes/schedules');
// A Monday-to-Sunday window well clear of the schedules' start dates. // A Monday-to-Sunday window well clear of the schedules' start dates.
const WEEK_START = new Date('2026-08-03T00:00:00'); // Monday const WEEK_START = new Date('2026-08-03T00:00:00'); // Monday
@ -37,28 +37,6 @@ const mk = (recurrence, startISO, recurrenceEnd = null) => ({
}); });
const weekdaysOf = (events) => events.map(e => new Date(e.instance_start).getDay()).sort(); const weekdaysOf = (events) => events.map(e => new Date(e.instance_start).getDay()).sort();
test('a date-only week anchor retains its calendar day west of UTC', () => {
// ⚠️ Record WHETHER it was set, not just its value. `process.env.TZ = undefined` writes the
// STRING "undefined", which Node cannot parse and silently resolves to UTC - changing the zone
// for every test that runs after this one in this file, all of which are date arithmetic.
const hadTz = Object.prototype.hasOwnProperty.call(process.env, 'TZ');
const originalTz = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
// new Date('2026-08-09') is UTC midnight, so it is still Saturday on a US server.
// The calendar date parser must retain the Sunday the browser selected.
assert.equal(new Date('2026-08-09').getDay(), 6, 'the legacy parser sees Saturday');
const selected = parseCalendarDate('2026-08-09');
assert.equal(selected.getFullYear(), 2026);
assert.equal(selected.getMonth(), 7);
assert.equal(selected.getDate(), 9);
assert.equal(selected.getDay(), 0, 'Sunday remains Sunday');
} finally {
if (hadTz) process.env.TZ = originalTz;
else delete process.env.TZ;
}
});
test('THE BUG: a Mon-Fri rule draws five events, not one', () => { test('THE BUG: a Mon-Fri rule draws five events, not one', () => {
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END); const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 5); assert.equal(ev.length, 5);

View file

@ -1,116 +0,0 @@
'use strict';
// THE BUG THIS PINS: "I add a schedule and it shows up on a different day."
//
// expandSchedule had two emit paths that disagreed about the wire format. A one-off passed
// schedule.start_time through untouched - a naive wall-clock string - while a recurring instance
// emitted cursor.toISOString(), an absolute instant. The browser parses the first in its own zone
// (correct) and converts the second out of the server's zone (wrong by the offset between them).
// For an operator in Tokyo against a US-Central server that is 14 hours: a Wednesday 20:00 event
// came back as Thursday 10:00.
//
// The calendar was also the only component doing this. services/scheduler.js compares start_time
// as a STRING and never builds a Date from it, so the drawing disagreed with playback as well as
// with the browser.
//
// These tests assert the PROPERTY the browser depends on - the wire value is wall-clock, and it
// means the same thing regardless of which zone reads it - rather than asserting a literal string,
// which would pass just as happily with the bug present in a differently-configured CI box.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-fmt-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-instance-format';
const { expandSchedule } = require('../routes/schedules');
// Wednesday 19 Aug 2026, 20:00 - late enough in the day that a westward server offset pushes it
// over midnight, which is exactly the case that was breaking.
const START = '2026-08-19T20:00:00';
const END = '2026-08-19T21:00:00';
const WALL_CLOCK = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
const rangeStart = new Date(2026, 7, 16, 0, 0, 0, 0); // Sun 16 Aug, local
const rangeEnd = new Date(2026, 7, 23, 0, 0, 0, 0); // Sun 23 Aug, local
const schedule = (recurrence) => ({
id: 1, start_time: START, end_time: END, recurrence, recurrence_end: null,
});
/* Run fn with the process in a given zone, restoring exactly what was there before. */
function inTimezone(tz, fn) {
const had = Object.prototype.hasOwnProperty.call(process.env, 'TZ');
const original = process.env.TZ;
process.env.TZ = tz;
try {
return fn();
} finally {
// ⚠️ Assigning `undefined` here would write the STRING "undefined", which Node cannot parse and
// silently resolves to UTC - quietly changing the zone for every test that runs afterwards in
// this process. Delete the key instead when it was not set to begin with.
if (had) process.env.TZ = original;
else delete process.env.TZ;
}
}
test('a recurring instance is emitted as wall-clock, not as an absolute instant', () => {
const events = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
assert.ok(events.length > 0, 'the rule should draw at least one event');
for (const ev of events) {
assert.match(ev.instance_start, WALL_CLOCK,
`instance_start must be a naive wall-clock string; got ${ev.instance_start}`);
assert.match(ev.instance_end, WALL_CLOCK,
`instance_end must be a naive wall-clock string; got ${ev.instance_end}`);
}
});
test('one-off and recurring agree on the wire format', () => {
// They are read by the same line of frontend code. If they disagree, one of them is wrong on
// every client whose zone differs from the server's - and which one is invisible from here.
const [oneOff] = expandSchedule(schedule(null), rangeStart, rangeEnd);
const [recurring] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
const shape = (v) => (WALL_CLOCK.test(v) ? 'wall-clock' : 'absolute');
assert.equal(shape(recurring.instance_start), shape(oneOff.instance_start),
'the two emit paths disagree about the wire format');
});
test('the emitted time is the time the operator chose', () => {
const [ev] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
assert.equal(ev.instance_start.slice(11, 16), '20:00',
'a 20:00 schedule must draw at 20:00');
assert.equal(new Date(ev.instance_start).getDay(), 3, 'Wednesday stays Wednesday');
});
test('THE REPORTED BUG: the day survives a server and browser in different zones', () => {
// Generate as a US-Central server would, then read it as a Tokyo browser would. With the bug,
// the recurring instance arrives as ...T01:00:00.000Z and Tokyo renders Thursday 10:00.
const wire = inTimezone('America/Chicago', () => {
const [ev] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
return ev.instance_start;
});
inTimezone('Asia/Tokyo', () => {
const seen = new Date(wire);
assert.equal(seen.getDay(), 3, `Tokyo should still see Wednesday, saw ${seen.toString()}`);
assert.equal(seen.getHours(), 20, `Tokyo should still see 20:00, saw ${seen.getHours()}:00`);
});
});
test('and the same holds when the server is EAST of the browser', () => {
// The mirror case, so a fix that merely shifts the offset in one direction cannot pass.
const wire = inTimezone('Asia/Tokyo', () => {
const [ev] = expandSchedule(schedule('FREQ=WEEKLY;BYDAY=WE'), rangeStart, rangeEnd);
return ev.instance_start;
});
inTimezone('America/Chicago', () => {
const seen = new Date(wire);
assert.equal(seen.getDay(), 3, `Chicago should still see Wednesday, saw ${seen.toString()}`);
assert.equal(seen.getHours(), 20, `Chicago should still see 20:00, saw ${seen.getHours()}:00`);
});
});

View file

@ -1,98 +0,0 @@
'use strict';
// TWO DATE BUGS IN THE SCHEDULE MODAL, both timezone-independent, both silent.
//
// 1. 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 it was editing. Change a colour on a block dated 5 Aug and it jumped to this week.
//
// 2. 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 this scope.
//
// Both are fixed by assigning the date on every path that OPENS the modal, which is the invariant
// pinned here. There is no DOM in this runner, so this is a source-level check - the same technique
// i18n-keys-exist.test.js uses to police the views. It is deliberately loose about HOW the value is
// assigned and strict about WHETHER each opener assigns it, so a refactor that keeps the invariant
// keeps passing.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SRC = fs.readFileSync(
path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'schedule.js'), 'utf8');
/*
* Comment lines are dropped before any of this is matched.
*
* The first version of this test failed against the FIXED code because the save handler carries a
* comment explaining why it does not use toISOString() - and the test matched the explanation.
* A source-level check has to look at code, not at prose about code.
*
* Whole-line comments only: stripping mid-line would mean parsing strings, and "https://" inside a
* URL literal looks exactly like a line comment to anything simpler than a parser.
*/
function withoutComments(text) {
return text
.split('\n')
.filter((line) => {
const t = line.trim();
return !(t.startsWith('//') || t.startsWith('*') || t.startsWith('/*'));
})
.join('\n');
}
/* The text of a brace-balanced block starting at `from`. */
function blockAt(from) {
const open = SRC.indexOf('{', from);
let depth = 0;
for (let i = open; i < SRC.length; i++) {
if (SRC[i] === '{') depth++;
else if (SRC[i] === '}') {
depth--;
if (depth === 0) return SRC.slice(open, i + 1);
}
}
throw new Error('unbalanced block');
}
const ASSIGNS_DATE = /pendingCreateDate\s*=/;
test('editSchedule records the date of the schedule it is editing', () => {
const at = SRC.indexOf('function editSchedule(');
assert.notEqual(at, -1, 'editSchedule() not found - has it been renamed?');
assert.match(withoutComments(blockAt(at)), ASSIGNS_DATE,
'editSchedule() must set pendingCreateDate, or saving an edit moves the schedule to today');
});
test('opening the blank Add form clears any date left over from a cancelled drag', () => {
const at = SRC.indexOf("getElementById('addScheduleBtn').onclick");
assert.notEqual(at, -1, 'the addScheduleBtn handler was not found');
assert.match(withoutComments(blockAt(at)), ASSIGNS_DATE,
'the Add handler must reset pendingCreateDate, or a cancelled drag-create stamps the next schedule');
});
test('the date is declared before anything assigns it', () => {
// It used to be declared BELOW the drag handler that assigns it. That happens to work only
// because the handler runs later; moving either one would turn it into a ReferenceError at a
// moment nobody is watching.
const code = withoutComments(SRC);
const decl = code.search(/\blet\s+pendingCreateDate\b/);
assert.notEqual(decl, -1, 'pendingCreateDate declaration not found');
const firstUse = code.search(/pendingCreateDate\s*=\s*(?!null\b)/);
assert.ok(decl < firstUse || firstUse === -1,
'pendingCreateDate is assigned before it is declared');
});
test('the saved date is built from local parts, never toISOString', () => {
// toISOString() is UTC: for anyone west of Greenwich it stamps the previous day for part of the
// evening, which is the same class of bug as the one on the server side.
const at = SRC.indexOf("getElementById('saveScheduleBtn').onclick");
assert.notEqual(at, -1, 'the save handler was not found');
const body = withoutComments(blockAt(at));
assert.ok(!/toISOString\(\)/.test(body),
'the save handler must not derive a calendar date from toISOString()');
assert.match(body, /getFullYear\(\)/, 'the date should be assembled from local parts');
});

View file

@ -1,82 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const Module = require('module');
/*
* A host where worker_threads cannot produce a thread must get a SLOWER server, not a dead one.
*
* This is not hypothetical. Running the server inside a BrightSign roHtmlWidget - a Node context
* inside an Electron renderer - the first spawn threw
*
* Failed to construct 'Worker': The V8 platform used by this instance of Node does not
* support creating Workers
*
* and killed the whole boot. The module already had the right behaviour for a worker that dies or
* cannot be respawned (engageFallback re-arms a conservative inline autocheckpoint); the initial
* spawn was simply the one path that was not wrapped. So the test asserts the OUTCOME - the server
* keeps going and the WAL is still bounded - rather than that some flag got set.
*/
function loadWithBrokenWorkers() {
// wal-checkpointer destructures Worker at module load, so the stub has to be in place before
// the require, and the module cache has to be clear of any earlier copy.
delete require.cache[require.resolve('../db/wal-checkpointer')];
const originalLoad = Module._load;
Module._load = function (request, parent, isMain) {
if (request === 'worker_threads') {
return {
Worker: class {
constructor() {
throw new Error('Failed to construct \'Worker\': The V8 platform used by this ' +
'instance of Node does not support creating Workers');
}
},
};
}
return originalLoad.apply(this, arguments);
};
try {
return require('../db/wal-checkpointer');
} finally {
Module._load = originalLoad;
delete require.cache[require.resolve('../db/wal-checkpointer')];
}
}
/* Just enough of a better-sqlite3 handle to record the pragmas the module issues. */
function fakeDb() {
const pragmas = [];
return { pragmas, pragma(sql) { pragmas.push(String(sql)); return []; } };
}
test('a host without worker threads degrades instead of taking the server down', () => {
const mod = loadWithBrokenWorkers();
const db = fakeDb();
// The whole point: this must not throw.
assert.doesNotThrow(() => mod.startWalCheckpointer(db, '/tmp/does-not-matter.db'));
// And it must leave the WAL bounded rather than unbounded: inline autocheckpoint is re-armed to
// a page count, NOT left at the 0 that the off-thread design sets on the way in.
const autocheckpoints = db.pragmas.filter((p) => p.startsWith('wal_autocheckpoint'));
assert.ok(autocheckpoints.length >= 2, 'expected autocheckpoint to be disabled then re-armed');
const last = autocheckpoints[autocheckpoints.length - 1];
assert.notStrictEqual(last, 'wal_autocheckpoint = 0',
'fallback must re-arm inline autocheckpoint, otherwise the WAL grows without bound');
const pages = Number(last.split('=')[1].trim());
assert.ok(Number.isFinite(pages) && pages > 0, `expected a positive page count, got: ${last}`);
mod.stopWalCheckpointer();
});
test('the fallback still reclaims the existing WAL backlog', () => {
const mod = loadWithBrokenWorkers();
const db = fakeDb();
mod.startWalCheckpointer(db, '/tmp/does-not-matter.db');
// PASSIVE or TRUNCATE both acceptable - which one depends on the WAL size. What matters is that
// a checkpoint was actually requested, so a WAL left by a previous run does not sit there.
assert.ok(db.pragmas.some((p) => p.startsWith('wal_checkpoint(')),
'fallback should reclaim the backlog');
mod.stopWalCheckpointer();
});

View file

@ -131,23 +131,16 @@ function applyHardwareIdentity(deviceId, data) {
const osVersion = str(di.hardware_os_version ?? data.bs_os_version); const osVersion = str(di.hardware_os_version ?? data.bs_os_version);
const rawOutput = di.output_index ?? data.bs_screen; const rawOutput = di.output_index ?? data.bs_screen;
const output = Number.isInteger(rawOutput) && rawOutput > 0 ? rawOutput : null; const output = Number.isInteger(rawOutput) && rawOutput > 0 ? rawOutput : null;
// Base64 of a 128 or 256 byte block, so ~172/344 chars. Capped generously to allow for a panel
// with several extension blocks while refusing anything that is clearly not an EDID — this is
// device-supplied and lands in a column the dashboard renders.
const edidRaw = typeof (di.hardware_edid ?? data.bs_edid) === 'string'
? (di.hardware_edid ?? data.bs_edid).trim().slice(0, 4096) || null
: null;
if (model == null && serial == null && osVersion == null && output == null && edidRaw == null) return; if (model == null && serial == null && osVersion == null && output == null) return;
db.prepare(`UPDATE devices SET db.prepare(`UPDATE devices SET
hardware_model = COALESCE(?, hardware_model), hardware_model = COALESCE(?, hardware_model),
hardware_serial = COALESCE(?, hardware_serial), hardware_serial = COALESCE(?, hardware_serial),
hardware_os_version = COALESCE(?, hardware_os_version), hardware_os_version = COALESCE(?, hardware_os_version),
output_index = COALESCE(?, output_index), output_index = COALESCE(?, output_index)
hardware_edid = COALESCE(?, hardware_edid)
WHERE id = ?`) WHERE id = ?`)
.run(model, serial, osVersion, output, edidRaw, deviceId); .run(model, serial, osVersion, output, deviceId);
} }
/* /*

View file

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