mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-18 16:13:51 -06:00
Compare commits
No commits in common. "main" and "v1.9.35" have entirely different histories.
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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
|
|
||||||
|
|
|
||||||
34
CHANGELOG.md
34
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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…</div>
|
|
||||||
<div class="url" id="url">—</div>
|
|
||||||
<table>
|
|
||||||
<tr><td class="k">uptime</td><td id="uptime">—</td>
|
|
||||||
<td class="k">memory</td><td id="mem">—</td></tr>
|
|
||||||
<tr><td class="k">disk</td><td id="disk">—</td>
|
|
||||||
<td class="k">database</td><td id="db">—</td></tr>
|
|
||||||
<tr><td class="k">node</td><td id="node">—</td>
|
|
||||||
<td class="k">load</td><td id="load">—</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…</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>
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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)}×${esc(device.edid.heightCm)} cm · ` : ''}${device.edid.digital ? 'digital' : 'analog'} · 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)} · ` : ''}${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 ? ' · 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')">▲</button>
|
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_UP')">▲</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')">▼</button>
|
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_DOWN')">▼</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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
7
frontend/vendor/README.md
vendored
7
frontend/vendor/README.md
vendored
|
|
@ -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:**
|
||||||
|
|
|
||||||
31
frontend/vendor/redoc.LICENSE
vendored
31
frontend/vendor/redoc.LICENSE
vendored
|
|
@ -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.
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/home/owner/Downloads/remote_display/server/node_modules
|
|
||||||
5
server/package-lock.json
generated
5
server/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue