mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 15:23:16 -06:00
Compare commits
184 commits
v1.9.29-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9459139f | ||
|
|
94a81b6896 | ||
|
|
86db5929c1 | ||
|
|
3ec06b663c | ||
|
|
8cb67122ad | ||
|
|
b13f11af13 | ||
|
|
dd7295792e | ||
|
|
114dc453bb | ||
|
|
955a691bcd | ||
|
|
702e107972 | ||
|
|
04a2ad99d1 | ||
|
|
243fc6688c | ||
|
|
741bc7b6a3 | ||
|
|
60dacad303 | ||
|
|
463e21f4d9 | ||
|
|
cb4b491840 | ||
|
|
23e80f2d27 | ||
|
|
d6c1a36d8c | ||
|
|
ab9ce40997 | ||
|
|
76f1fb7e7a | ||
|
|
66df64a798 | ||
|
|
66b5a9cc9d | ||
|
|
26c059c1b8 | ||
|
|
414c1e9ab5 | ||
|
|
8b0601b7bc | ||
|
|
feda25943c | ||
|
|
0b10776701 | ||
|
|
521025a073 | ||
|
|
58fdf8122c | ||
|
|
13534d9b61 | ||
|
|
b906bfa65f | ||
|
|
e9bd8ac8af | ||
|
|
8b162ecce2 | ||
|
|
7934156a2e | ||
|
|
3234c923a3 | ||
|
|
1fc50ec263 | ||
|
|
cb9f6a84df | ||
|
|
128a5be1b1 | ||
|
|
c436a44c89 | ||
|
|
13c9c67335 | ||
|
|
aa09631d69 | ||
|
|
2ed87f5cfb | ||
|
|
717d192d5f | ||
|
|
d4d95b6b92 | ||
|
|
72fd2314b5 | ||
|
|
f796876d91 | ||
|
|
b28924012d | ||
|
|
4bc0d433b0 | ||
|
|
350ca58f22 | ||
|
|
dd18a795ee | ||
|
|
b0de7ee208 | ||
|
|
3617a1a116 | ||
|
|
ffceaf2c1f | ||
|
|
a5cde06ed7 | ||
|
|
bc95f58d66 | ||
|
|
184ff71dee | ||
|
|
6cd697c3fc | ||
|
|
1dc63ce84c | ||
|
|
bd0b39168f | ||
|
|
e5e5b75b85 | ||
|
|
77d41ae73e | ||
|
|
38d72afd3f | ||
|
|
c15edc1bb8 | ||
|
|
b1a58144bb | ||
|
|
6830fe58ea | ||
|
|
78a403d35c | ||
|
|
226c96c17e | ||
|
|
6aeb703efe | ||
|
|
bddb78371f | ||
|
|
ec450929ce | ||
|
|
8361392ebd | ||
|
|
fbf55f842c | ||
|
|
94e1273ecd | ||
|
|
85febe05c0 | ||
|
|
37e22bb773 | ||
|
|
355b7a2b86 | ||
|
|
983bee31b7 | ||
|
|
901e664591 | ||
|
|
601b526264 | ||
|
|
240f107f17 | ||
|
|
751d5343da | ||
|
|
0e8ffa5444 | ||
|
|
481a44c15a | ||
|
|
c91b96ab91 | ||
|
|
d7227e6828 | ||
|
|
9155370ae8 | ||
|
|
d0c7ba28b7 | ||
|
|
d4b8d7dad4 | ||
|
|
d26aaebef6 | ||
|
|
295f4aecb1 | ||
|
|
1e329b0b80 | ||
|
|
e97228a502 | ||
|
|
a88687a11b | ||
|
|
252854d31e | ||
|
|
d7f87ce0bd | ||
|
|
eeab23e0d3 | ||
|
|
1e278ea373 | ||
|
|
4fe2552971 | ||
|
|
9f83832f7d | ||
|
|
f725186905 | ||
|
|
2cbb8e6349 | ||
|
|
28885d1a13 | ||
|
|
9e5d05aa33 | ||
|
|
9b6f0856e0 | ||
|
|
20d36e8923 | ||
|
|
08b3d7d404 | ||
|
|
def30e6d39 | ||
|
|
c2033b95fb | ||
|
|
6ac272af57 | ||
|
|
3333d5e968 | ||
|
|
f58c537d15 | ||
|
|
04068f7f0a | ||
|
|
b9bd83c48f | ||
|
|
047f95f40c | ||
|
|
24e430b354 | ||
|
|
c594a1a67a | ||
|
|
a44d3f8a77 | ||
|
|
ab44a2efc6 | ||
|
|
b9b1870472 | ||
|
|
f17eaed8bc | ||
|
|
4b6194884b | ||
|
|
77e3081675 | ||
|
|
1ec32197b2 | ||
|
|
f0f7f35103 | ||
|
|
04f88d3340 | ||
|
|
95b8d1b293 | ||
|
|
0953823ee5 | ||
|
|
0efbc6040e | ||
|
|
8b4b7af432 | ||
|
|
5b069b9665 | ||
|
|
b00efa4f14 | ||
|
|
a6a137daf2 | ||
|
|
127a6265e0 | ||
|
|
9face2fdd4 | ||
|
|
7896cccf4d | ||
|
|
bfe8a4c907 | ||
|
|
3f1c044940 | ||
|
|
7d7be365f9 | ||
|
|
0f2ec474f4 | ||
|
|
16d8295373 | ||
|
|
5fa6b2d07d | ||
|
|
bf9ad16486 | ||
|
|
70b2227fa8 | ||
|
|
b29e3b4676 | ||
|
|
fc1331337f | ||
|
|
89cdd1052a | ||
|
|
59489b3b20 | ||
|
|
60deefd992 | ||
|
|
b4363d8d26 | ||
|
|
4f7b4e3989 | ||
|
|
ac2389716c | ||
|
|
c778f050a9 | ||
|
|
dc056a5a59 | ||
|
|
3b7cd67ef5 | ||
|
|
32189d1784 | ||
|
|
d7ee971543 | ||
|
|
9a630087ff | ||
|
|
e313826d85 | ||
|
|
e812f35b6b | ||
|
|
a1aeb324d7 | ||
|
|
5d56e538af | ||
|
|
3d9ef039e5 | ||
|
|
3e37d33b80 | ||
|
|
4e1de8ec0e | ||
|
|
c1270599c3 | ||
|
|
2237edab12 | ||
|
|
4d86a75196 | ||
|
|
97f53a5b72 | ||
|
|
63b9de1329 | ||
|
|
e4c25c39df | ||
|
|
52ab04204a | ||
|
|
6471c503ab | ||
|
|
aa77332c0d | ||
|
|
afe3f7f57f | ||
|
|
c2240288a7 | ||
|
|
3b9ad08454 | ||
|
|
996b0ab6c0 | ||
|
|
db8846a139 | ||
|
|
c2effc9f5f | ||
|
|
a85e067260 | ||
|
|
c226bdddc6 | ||
|
|
647a2bcc56 | ||
|
|
76ba6c7506 | ||
|
|
0a888910dc |
63
.github/workflows/ci.yml
vendored
63
.github/workflows/ci.yml
vendored
|
|
@ -25,6 +25,15 @@ jobs:
|
|||
working-directory: server
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# The player-parity baselines judge a claim against the SHIPPED source
|
||||
# (`git show <latest tag>:…`), because a baseline describes what an
|
||||
# UN-UPDATED display can do. The default shallow checkout has no tags, so
|
||||
# the suite silently fell back to the working tree and the biconditionals
|
||||
# inverted: fixing a player's payload bug made CI demand a baseline change
|
||||
# for displays that cannot possibly have the fix yet. Green locally, red
|
||||
# here, for a reason found nowhere in the diff.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
|
@ -77,6 +86,39 @@ jobs:
|
|||
working-directory: android
|
||||
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:
|
||||
name: Boot smoke + version check
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -100,6 +142,12 @@ jobs:
|
|||
working-directory: server
|
||||
env:
|
||||
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: |
|
||||
node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
|
||||
echo $! > "$RUNNER_TEMP/server.pid"
|
||||
|
|
@ -122,6 +170,21 @@ jobs:
|
|||
test "$REPORTED" = "$EXPECTED"
|
||||
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
|
||||
if: always()
|
||||
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true
|
||||
|
|
|
|||
74
.github/workflows/release.yml
vendored
74
.github/workflows/release.yml
vendored
|
|
@ -85,6 +85,15 @@ jobs:
|
|||
./scripts/build-autorun-zip.sh -o 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)
|
||||
run: |
|
||||
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
|
||||
|
|
@ -100,15 +109,42 @@ jobs:
|
|||
- name: Generate release notes
|
||||
run: |
|
||||
PREV="${{ steps.ver.outputs.prev }}"
|
||||
VERSION="${{ steps.ver.outputs.version }}"
|
||||
|
||||
# Prefer the hand-written CHANGELOG section for this version.
|
||||
#
|
||||
# The generated list is commit SUBJECTS, which describe the work, not the release: cutting
|
||||
# 1.9.34 produced a page reading "chore(release): v1.9.34" and one changelog commit, while
|
||||
# the entry describing single sign-on, the native-dependency removal, the update fixes and
|
||||
# every outside contributor sat in CHANGELOG.md and was never published. The notes on the
|
||||
# release page are what most people actually read, so they should be the written ones.
|
||||
#
|
||||
# awk rather than sed: the body contains regex metacharacters and markdown that a sed range
|
||||
# would mangle. This takes everything between `## <version>` and the next `## ` heading.
|
||||
CHANGELOG_BODY="$(awk -v v="## $VERSION" '
|
||||
$0 == v {found=1; next}
|
||||
found && /^## / {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md)"
|
||||
|
||||
{
|
||||
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$PREV" ]; then
|
||||
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
|
||||
if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
|
||||
echo "$CHANGELOG_BODY"
|
||||
else
|
||||
echo "_First tagged release. Most recent changes:_"
|
||||
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
|
||||
# No entry for this version — fall back to commit subjects rather than publish a
|
||||
# release with no notes at all. scripts/bump-version.sh already warns when the
|
||||
# CHANGELOG has no matching heading; this is the same gap showing up downstream.
|
||||
echo "_No CHANGELOG entry for $VERSION; listing commits instead._"
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$PREV" ]; then
|
||||
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
|
||||
else
|
||||
echo "_First tagged release. Most recent changes:_"
|
||||
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
echo "### Artifacts"
|
||||
|
|
@ -127,6 +163,7 @@ jobs:
|
|||
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
|
||||
fi
|
||||
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
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
|
|
@ -144,6 +181,7 @@ jobs:
|
|||
--notes-file RELEASE_NOTES.md \
|
||||
"${TARBALL}" \
|
||||
autorun.zip \
|
||||
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
|
||||
tizen/ScreenTinker.wgt
|
||||
|
||||
docker:
|
||||
|
|
@ -171,7 +209,33 @@ jobs:
|
|||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# ghcr refused this push on the 1.9.29 release with "denied: permission_denied: Error from
|
||||
# intermediary with HTTP status code 403", then accepted the identical build on a manual
|
||||
# re-run minutes later. Nothing about the token, the permissions or the workflow changed in
|
||||
# between — the registry simply said no once.
|
||||
#
|
||||
# That is worth one retry rather than a failed release, and it is worst exactly here: the
|
||||
# GitHub Release job has already published by this point, so a failure leaves a tag that
|
||||
# exists with no image behind it. Anyone deploying from ghcr — alpha, and every self-hoster
|
||||
# pulling :latest — sees a version that is announced and unpullable, which reads as a broken
|
||||
# release rather than a hiccup at a registry.
|
||||
- uses: docker/build-push-action@v6
|
||||
id: push
|
||||
continue-on-error: true
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.ver.outputs.tags }}
|
||||
|
||||
- name: Pause before retrying the push
|
||||
if: steps.push.outcome == 'failure'
|
||||
run: sleep 45
|
||||
|
||||
# No continue-on-error: a second refusal is a real failure and must fail the release.
|
||||
- name: Retry the push
|
||||
if: steps.push.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -59,3 +59,7 @@ audit/
|
|||
# Local SQLite artifacts (any extension the tooling might produce)
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset)
|
||||
sbom/
|
||||
*.cdx.json
|
||||
|
|
|
|||
772
CHANGELOG.md
772
CHANGELOG.md
|
|
@ -1,5 +1,777 @@
|
|||
# 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
|
||||
|
||||
A maintenance release. Two faults where the product was working correctly and still looked broken to
|
||||
whoever was standing in front of the screen, plus the dependency advisories that could reach a running
|
||||
server.
|
||||
|
||||
No migrations and no configuration changes. See the upgrade note at the end of this entry.
|
||||
|
||||
### Fixed — a player could get stuck on an update it was never able to install
|
||||
|
||||
A staged update is saved under a filename built from the version the server advertised. If a server
|
||||
advertised one version while still serving the file for an older one, the player saved the old file
|
||||
under the new name — and from then on found it, verified its signature, accepted it, and installed
|
||||
something that changed nothing. The version never moved, so the same update was offered again, and the
|
||||
player retried the same no-op until it hit its attempt limit.
|
||||
|
||||
The signature check passed the whole time, correctly: the file was genuine, it was simply the wrong
|
||||
one. Worse, fixing the server did not help, because the bad file was reused before anything was
|
||||
downloaded. Recovery meant deleting the file on the device by hand.
|
||||
|
||||
A staged update is now reused only when the version inside the file matches the version being
|
||||
installed, and a fresh download is checked the same way before it is applied. A server serving the
|
||||
wrong file now says so — *"server served 1.9.33 but advertised 1.9.34 — the update on the server is
|
||||
stale"* — and the file is deleted instead of kept. That makes this self-healing: once the server is
|
||||
corrected, the player recovers on its own.
|
||||
|
||||
**Clear update cache** on the device page discards every staged update on a player. The version check
|
||||
should make it rarely necessary; it exists because a player already holding a bad file predates this
|
||||
release and cannot benefit from the check, and because the alternative is a cable and a laptop.
|
||||
|
||||
### Fixed — directory search showed the system keyboard on top of its own
|
||||
|
||||
The directory-search widget draws its own on-screen keyboard, sized and themed to the panel and on by
|
||||
default. On Android it was never visible. The page puts the cursor in a real text field, which is the
|
||||
signal for the device to raise its system keyboard — over the bottom of the screen, exactly where the
|
||||
widget's keyboard is.
|
||||
|
||||
So a wall-mounted directory showed the phone keyboard: split across the screen, with microphone, GIF,
|
||||
emoji and a settings key that opens the keyboard vendor's own interface on a kiosk. On one panel the
|
||||
only keyboard installed was voice input, so touching the search box opened a microphone. The widget's
|
||||
own keyboard had been underneath the whole time.
|
||||
|
||||
When the widget draws a keyboard, it now tells the device not to raise one. Turn the built-in keyboard
|
||||
off and the system keyboard behaves as before — with nothing to cover, it is the only way left to type.
|
||||
|
||||
### Changed — the dependency advisories that could reach a running server are cleared
|
||||
|
||||
Every high-severity advisory affecting a production install is resolved, including eight in the mail
|
||||
library covering SMTP command injection and header injection. The remaining advisories are in
|
||||
development-only tooling that is not installed on a server and cannot be reached from one.
|
||||
|
||||
The real-time connection to players is deliberately untouched: the fix there was a patch to the message
|
||||
parser with no change to the format players speak, so nothing about an existing player's connection
|
||||
changes.
|
||||
|
||||
Sending mail was previously covered only by tests that substituted the mail library for a stand-in,
|
||||
which would have stayed green through any change in the library itself. It is now also tested against
|
||||
the real one.
|
||||
|
||||
### Added — an install that collects statistics can show the total on its landing page
|
||||
|
||||
Where install statistics are being collected, the landing page can show how many screens have been
|
||||
deployed in total. It is an aggregate across every install that chooses to report, so it says nothing
|
||||
about any single one.
|
||||
|
||||
This does nothing on a normal install: the figure is served only where collection is switched on, so a
|
||||
private server never publishes its own screen count, and the line is hidden entirely rather than
|
||||
showing a zero.
|
||||
|
||||
### Changed — release notes are the written ones
|
||||
|
||||
Published release notes now come from this file rather than from a list of commit subjects. The
|
||||
previous release announced itself as one commit titled "chore(release)" while the entry describing it
|
||||
sat here unread.
|
||||
|
||||
### ⚠️ Upgrading from 1.9.34 reinstalls dependencies
|
||||
|
||||
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in
|
||||
both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
|
||||
startup where it can reach the npm registry.
|
||||
|
||||
Docker deployments need no action; dependencies are installed inside the image.
|
||||
|
||||
## 1.9.34
|
||||
|
||||
Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what
|
||||
was there before. Alongside it: the last native image dependency is gone, several players that
|
||||
could not install updates now can, and an install can optionally report how many screens it runs.
|
||||
|
||||
No migrations and no configuration changes. See the upgrade note at the end of this entry.
|
||||
|
||||
### Fixed — the old sign-in path could be replayed by any site you had signed into
|
||||
What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and
|
||||
then trusted the email address that came back, never asking the only question that matters: *who was
|
||||
this token issued for?* Any other site a user had signed into — anything holding a token with the
|
||||
right scope — could replay it against ScreenTinker and receive a session as that user. No password,
|
||||
no interaction from the victim.
|
||||
|
||||
Identity now comes from an **ID token only**, with the signature checked against the provider's
|
||||
keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider:
|
||||
Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries
|
||||
rather than hand-written special cases, which is what removed the two paths that were wrong.
|
||||
|
||||
### Added — organizations bring their own single sign-on
|
||||
Instance-wide providers stay the default and are now unlimited in number. On top of that, an
|
||||
organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking
|
||||
OpenID Connect — configured by that organization's own admins in Settings, with no operator
|
||||
involvement and no restart.
|
||||
|
||||
A provider may only assert addresses at domains the organization has **proved it controls**, via a
|
||||
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
|
||||
releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to
|
||||
one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard
|
||||
zone we do not operate, and it would turn a subdomain takeover into an apex takeover.
|
||||
|
||||
An organization's provider never appears publicly. The login page reveals it only after someone
|
||||
enters an address at a verified domain, so a guessed domain cannot confirm who your customers are.
|
||||
|
||||
**Require single sign-on** is available per organization: passwords refused, other providers
|
||||
refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would
|
||||
just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the
|
||||
request, so one compromised org admin cannot quietly reopen password login. Break-glass for a
|
||||
platform administrator is the correct password and nothing else, and a wrong password returns the
|
||||
same refusal everyone else gets, so it cannot be used to discover whether an account exists.
|
||||
|
||||
⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible
|
||||
without a reset.
|
||||
|
||||
Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other
|
||||
grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator,
|
||||
and an organization's own provider is believed once it has verified a domain — the DNS proof stands
|
||||
in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has
|
||||
verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone.
|
||||
Other providers that verify addresses without saying so can opt in with
|
||||
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
|
||||
|
||||
**With no SSO environment variables set, the product behaves exactly as it did before.**
|
||||
|
||||
### Added — an existing account can move to single sign-on
|
||||
Signing in with a provider has always refused to take over an account that already has a password,
|
||||
and that refusal is right — otherwise anyone who could persuade a provider to assert your address
|
||||
would inherit your account. But the way out had never been built, so an account created with a
|
||||
password simply could not use single sign-on.
|
||||
|
||||
**Settings → Sign-in method** now offers it, in both directions. An account has exactly **one**
|
||||
credential: linking **deletes** the password, and the confirmation says so, because a password left
|
||||
behind is a second way in that you believe you replaced. Unlinking asks for the new password first
|
||||
and applies both changes together, so the account is never left without a way in.
|
||||
|
||||
The account being linked is the one you are **signed in as**, never whichever account matches the
|
||||
address the provider returns — that is what separates linking from the takeover the login page
|
||||
refuses. Only providers configured on this server can be linked; an organization's own provider
|
||||
cannot attach itself to an account.
|
||||
|
||||
### Changed — the login page asks who you are before how you sign in
|
||||
The password box appears once you have entered your address and continued, rather than sitting there
|
||||
from the start. That is what lets the page check whether your organization uses single sign-on
|
||||
*before* offering you a credential, so someone whose company requires it is shown that rather than a
|
||||
password box that was always going to be refused. Correcting your address takes you back a step.
|
||||
|
||||
The address is no longer looked up on every keystroke — it answered for half-finished domains,
|
||||
changed the form under you mid-address, and could exhaust a shared office network's lookup budget
|
||||
before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so
|
||||
the page no longer changes shape while you type.
|
||||
|
||||
Setup instructions for both operators and organization admins are in
|
||||
[docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications
|
||||
— including the one that catches everyone: the Microsoft tenant setting names the directory that
|
||||
*authenticates the user*, which for personal accounts is not the directory the application is
|
||||
registered in.
|
||||
|
||||
### Changed — image processing no longer needs a native library
|
||||
Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and
|
||||
avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and
|
||||
`better-sqlite3` is the only native module left.
|
||||
|
||||
A native module needs a prebuilt binary matching both the platform and the Node version; when there
|
||||
isn't one the server fails at load with an error that reads like database corruption rather than a
|
||||
missing image library. That class of failure is gone from this half of the product.
|
||||
|
||||
Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif
|
||||
through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library
|
||||
in use decodes AV1 but refuses HEVC.
|
||||
|
||||
Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a
|
||||
12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks
|
||||
an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats
|
||||
and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with
|
||||
serving requests.
|
||||
|
||||
### Fixed — players that could not install an update
|
||||
Three separate faults, each able to strand a player on an old version.
|
||||
|
||||
**Updates were written to external storage.** Where that location is absent, or exists but cannot be
|
||||
written to, the download failed the instant it began — before any data arrived — and reported only
|
||||
that it had failed to download or verify. The same player could be caching content perfectly well
|
||||
throughout, because content goes to internal storage. Updates now go to the first location that
|
||||
genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to
|
||||
it* rather than by asking whether it is writable — the previous check asked, was told yes, and the
|
||||
write failed anyway.
|
||||
|
||||
**Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one
|
||||
numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved
|
||||
forward, while the server named the newer build as latest in the same reply. Numbers in version
|
||||
names are now compared as numbers. The BrightSign host package carried the same comparison and is
|
||||
fixed with it — there, a wrong answer replaces the script that starts the player.
|
||||
|
||||
**A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate
|
||||
comes from a legacy path that can return nothing. The player now reads the signature itself before
|
||||
giving up. Verification is unchanged: the certificate is still compared against the installed app,
|
||||
and anything unsigned, tampered with, or signed by a different key is still rejected.
|
||||
|
||||
A failed update now also says which of those things went wrong, instead of one message covering
|
||||
every possible cause.
|
||||
|
||||
⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how
|
||||
updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by
|
||||
hand, after which it recovers on its own and stays fixed.
|
||||
|
||||
### Fixed — the Android player could leave a band down one edge of the screen
|
||||
A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar.
|
||||
It was intermittent because it depended on whether the app was measured before or after the system
|
||||
UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart.
|
||||
The stage is now measured from the current window and re-measured when focus changes.
|
||||
|
||||
Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem;
|
||||
pinning the output resolution fixed the corruption, and this fixes the band that remained.
|
||||
|
||||
### Added — opt-in install statistics
|
||||
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
|
||||
design and should stay that way. A platform administrator is asked, once, whether this install will
|
||||
share how many screens it runs.
|
||||
|
||||
The whole payload is three fields — a random instance ID, the version, and the screen count — and
|
||||
nothing else: no hostnames, addresses, organization or user names, device names, content or
|
||||
configuration. Settings shows the **actual payload this server would send**, generated live from its
|
||||
own data, alongside what it last really sent and when, so the claim can be checked rather than taken
|
||||
on trust. Turning it on reports immediately, and a blocked outbound connection is named along with
|
||||
the address to allow, rather than failing silently.
|
||||
|
||||
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
|
||||
return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you
|
||||
run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who
|
||||
wants their own numbers and nothing sent to us can set it and leave sharing off.
|
||||
|
||||
The random ID exists only so repeat reports from one server count as one server, which makes a
|
||||
report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in,
|
||||
any total published from it is a floor, never an estimate of the install base. Full detail in
|
||||
[docs/telemetry.md](docs/telemetry.md).
|
||||
|
||||
### Added — organizations may re-enable same-origin widgets, deliberately
|
||||
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce
|
||||
strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a
|
||||
typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or
|
||||
admin — a workspace admin is deliberately not enough — and the change is written to the activity
|
||||
log. Contributed by @ChrisChrome.
|
||||
|
||||
The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard
|
||||
where the admin's session token lives, so honouring the setting there would let anyone who can
|
||||
author a widget lift the session of whichever admin clicked Preview. The setting exists so
|
||||
*displays* can embed origin-strict sites; a display holds a device token, an admin's browser does
|
||||
not.
|
||||
|
||||
### Fixed — RSS tickers ran at a speed that depended on how much news there was
|
||||
Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so
|
||||
a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read,
|
||||
and it appeared to jump back to the start. It now holds a constant rate, so more items simply take
|
||||
proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
|
||||
|
||||
### Fixed — user-controlled text is escaped where it reaches the page
|
||||
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
|
||||
Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when
|
||||
switching views, and the main content no longer collapses to a narrow column.
|
||||
|
||||
### Added — an operations runbook
|
||||
[docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both
|
||||
shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only
|
||||
obvious once they have bitten you — including three from a Raspberry Pi 5 report, two of which are
|
||||
not Pi-specific. A piped installer cannot really ask you anything, because the pipe is its input and
|
||||
every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor
|
||||
hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD
|
||||
card by discarding writes — safe for a player, quietly destructive for a server whose database is
|
||||
written continuously.
|
||||
|
||||
### Changed — `better-sqlite3` pinned to 12.9.0
|
||||
Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database
|
||||
driver can move independently rather than as one flag day.
|
||||
|
||||
The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both
|
||||
the current and the next Node major; later 12.x releases dropped the older one while still
|
||||
advertising support for it. A caret range would resolve to one of those and silently turn
|
||||
installation into a source build. Nothing in the query API changed.
|
||||
|
||||
### ⚠️ Upgrading from 1.9.33 reinstalls dependencies
|
||||
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** —
|
||||
in both directions.
|
||||
|
||||
- **Upgrading**: `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
|
||||
startup where it can reach the npm registry.
|
||||
- **Rolling back past this release**: mandatory. Earlier builds load a native image library at
|
||||
runtime that this release removes, so rolling back the code without reinstalling leaves a server
|
||||
whose image ingest cannot load its decoder.
|
||||
|
||||
Docker deployments need no action either way; dependencies are installed inside the image.
|
||||
|
||||
### Known limitations
|
||||
Deliberately unresolved, and worth knowing:
|
||||
|
||||
- Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly
|
||||
without a reset.
|
||||
- Turning that requirement back off depends on a platform administrator approving the request; if
|
||||
nobody does, the organization stays on single sign-on.
|
||||
- `landing.html` still interpolates plan names into HTML without escaping. Those values come from
|
||||
the plans table rather than from end users, so it is a loose end rather than an exposure.
|
||||
- `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves
|
||||
some waiting. Pre-existing and unchanged by this release.
|
||||
|
||||
### Thanks
|
||||
This release — and a good deal of what came before it — exists because people outside the project
|
||||
reported problems and sent patches. Credit was recorded inconsistently at the time, so it is
|
||||
collected here rather than left scattered.
|
||||
|
||||
**Code contributed**
|
||||
|
||||
- **@ChrisChrome** — the organization-level widget sandbox toggle (#254) and the RSS ticker rate fix,
|
||||
both in this release. Earlier: the Debian player/server install script (#137) and web player
|
||||
auto-connect (#6).
|
||||
- **@BlazzzPlay** — eight merged pull requests across 1.9.4 to 1.9.13: server-side preview sessions
|
||||
to work around CSP (#151), the Android hidden settings menu (#152), sending device identity on
|
||||
reconnect before pairing (#164), the dashboard version indicator and update check (#165, #181),
|
||||
authenticated thumbnail loading (#182), the server URL in the Add Display modal and the Releases
|
||||
link on the APK download page (#210), and uploads respecting the current folder (#211).
|
||||
- **@a10kiloham** — boot-time thumbnail healing with ffmpeg diagnostics and packaging (#244), the
|
||||
screenshot-request verdict toast and the reverse-proxy header pitfall it documented (#243), and a
|
||||
configurable maximum upload size (#233).
|
||||
- **@albanobattistella** — the Italian translation, and its updates since (#2, #145, #232).
|
||||
|
||||
**Reported**
|
||||
|
||||
- **@carloblu74** — the Raspberry Pi 5 report behind #245, which found five defects in the installer
|
||||
and kiosk launcher that nothing in this repository would have caught, because nothing here had ever
|
||||
executed those scripts on a Pi. The runbook notes above come from it.
|
||||
- **@bold-media-group** — by a wide margin the largest source of field reports, across roughly fifty
|
||||
issues: the OTA rollout and version-advertising problems, event-loop lag under long uptime, video
|
||||
wall behaviour, Tizen playback regressions, and the content-loading failures that led to resumable
|
||||
downloads.
|
||||
- **@Smiley-k**, **@Semetra22**, **@patrickfinardi09**, **@hapishyguy**, **@Nikhil12656**,
|
||||
**@gittyguy92** and **@Obe-BoldMediaGroup** — bug reports and feature requests across the 1.9.x
|
||||
line, including SMTP transport, playlist item scheduling, and the Android playlist-order fault
|
||||
behind #234.
|
||||
|
||||
Several of the hardest faults this year were found by someone running the product on hardware the
|
||||
project does not own. That is worth saying plainly.
|
||||
|
||||
## 1.9.33
|
||||
|
||||
A patch off 1.9.32. The headline is a boot-time crash that could brick a display permanently — a
|
||||
player that died on startup, every startup, and could not be recovered by rebooting it. The rest is
|
||||
the live debug log finally working on the web player, and the playlist-skipping bug that log found
|
||||
within minutes of being switched on.
|
||||
|
||||
### Fixed — a cached playlist could brick a display across reboots
|
||||
The most serious of these. On startup the player restores its **cached** playlist and renders the
|
||||
first item immediately. If that item was a video carrying a transition, it read an internal flag
|
||||
before that flag's declaration had run — which in JavaScript is a *throw*, not an empty value. The
|
||||
player died during boot.
|
||||
|
||||
The loop is what made it fatal rather than annoying: the playlist came from the display's own local
|
||||
cache, so it never stayed up long enough to receive a corrected one. Every boot re-read the same
|
||||
cache and died the same way. **Rebooting the player — the one remedy an operator has — did nothing.**
|
||||
Recovery meant changing the player the server hands out; nothing in the dashboard would have helped.
|
||||
|
||||
Found on a BrightSign, but nothing about it was BrightSign-specific: any browser-based display could
|
||||
have hit it. No customer display was in this state, and the one playlist that mixed video with a
|
||||
transition happened to start on an image, which was luck rather than protection.
|
||||
|
||||
### Fixed — one broken clip could skip several playlist items
|
||||
A media error scheduled a skip *per error event*, and each new skip orphaned the previous timer
|
||||
instead of cancelling it, so all of them fired. Four decode errors on one clip meant four advances.
|
||||
On a single-item playlist that merely replayed the same file, which is why it hid for so long; on a
|
||||
real playlist it silently dropped the next three items and nothing said why.
|
||||
|
||||
One failure now means one skip. A clip that is still playable is no longer discarded on a stray
|
||||
event, while anything genuinely undecodable is still skipped, so a broken file can never stall a
|
||||
playlist. Failures also now report the actual media error instead of an anonymous "Video error".
|
||||
|
||||
### Added — the live debug log works on browser-based displays
|
||||
The per-device **Debug logging** checkbox has always sent its command, but only the Android player
|
||||
ever answered it. The panel opened on every other display and streamed almost nothing.
|
||||
|
||||
It now streams what the player has always been recording internally: its own log, uncaught errors
|
||||
with file and line, failed downloads, and on BrightSign the host's boot report. Switching it on also
|
||||
**replays what was buffered before you opened it**, timestamped with how long ago each line really
|
||||
happened — so the failure you came to investigate is already on screen instead of needing to happen
|
||||
again.
|
||||
|
||||
It matters most where there is no alternative: on a signage player there is no console to open and
|
||||
no cable to attach, and this is the only way to see what the display thinks it is doing.
|
||||
|
||||
**Freeze** holds the view still while continuing to buffer underneath, because the moment you freeze
|
||||
a log is the moment the lines explaining it are still arriving. **Copy** puts the visible capture on
|
||||
the clipboard, stamped with the display and time, and works on self-hosted dashboards served over
|
||||
plain HTTP where the browser clipboard API is unavailable. Errors and warnings are now coloured, so
|
||||
the one line that explains the fault no longer sits in a wall of grey.
|
||||
|
||||
### Changed — display controls sit above the status panels
|
||||
Reboot, screen on/off, launch, force update and shutdown were flush against the status cards, which
|
||||
read as though they belonged to them.
|
||||
|
||||
## 1.9.32
|
||||
|
||||
A patch off 1.9.31. The headline is that a BrightSign can finally photograph its own screen; the
|
||||
rest is a thumbnail library that heals itself, a Raspberry Pi installer that asks the operator
|
||||
rather than the pipe, IPv6 on the dashboard, and a pairing code you can read from across a room.
|
||||
|
||||
### Fixed — a BrightSign can now screenshot itself, video included
|
||||
That platform has never managed it. Video decodes onto a hardware plane the DOM cannot read, so the
|
||||
player's in-page canvas composite came back with the content missing, and the panel truthfully but
|
||||
uselessly reported *"Video is playing on the hardware plane and cannot be captured"* while playing
|
||||
perfectly.
|
||||
|
||||
It now uses **BrightSign's own `@brightsign/screenshot` API**, which composites the video and
|
||||
graphics layers — exactly the thing a canvas cannot do. The capture is written to RAM rather than
|
||||
the boot flash: the remote-control view asks for one every second, and a screenshot per second
|
||||
written to flash wears it out for nothing, since the file is read back and deleted immediately.
|
||||
|
||||
Remote control gets it for free — the live view and the screenshot button share one capture path,
|
||||
so the live view now shows real video instead of a card explaining why it can't.
|
||||
|
||||
The long way round is kept as a fallback for firmware without the module, and its own bug is fixed
|
||||
on the way: the host asked the player's diagnostic web server on a hardcoded port 80, while that
|
||||
port is configurable and commonly moved (the unit this was found on serves it on 8080 with nothing
|
||||
on 80 at all). It now reads the port from the registry the server is configured from.
|
||||
|
||||
### Fixed — per-item dayparting was silently dead on BrightSign
|
||||
A BrightSign widget runs with Node integration, which puts `module` into the page's scope. Every
|
||||
shared module that exported with an `else` therefore took the CommonJS branch and never assigned
|
||||
its browser global — and every consumer has a silent fallback, so nothing ever complained.
|
||||
|
||||
The visible casualty was the transition engine, which is gated on exactly those globals and so
|
||||
never initialised. The costly one was `schedule-eval`: without it the player falls back to "always
|
||||
active", so **scheduled content played outside its window** on that platform, with nothing in any
|
||||
log. Modules now export to both targets.
|
||||
|
||||
### Fixed — thumbnails that never appear, and never retry
|
||||
Thumbnail generation is best-effort by contract, and three gaps made its failures invisible and
|
||||
permanent: ffmpeg is a system dependency nothing surfaced (and the Docker image did not install
|
||||
it), a row that missed generation was never retried, and a failed image thumbnail stored a path to
|
||||
a file that was never written — which the dashboard then requested forever as a broken image.
|
||||
|
||||
There is now a `[MEDIA]` startup diagnostic, a once-per-boot backfill that heals old rows, ffmpeg in
|
||||
the runtime image, and the phantom path is gone. Video probing moved off the synchronous spawn it
|
||||
had always used: two subprocess calls with a 15-second timeout each, run synchronously, stop the
|
||||
whole server for their duration — survivable for one human-initiated upload, not for a sweep
|
||||
walking an entire library unattended.
|
||||
|
||||
### Fixed — Raspberry Pi 5 installer (#245)
|
||||
`curl … | sudo bash` makes stdin the *script*, and bash has consumed it by the time any prompt
|
||||
runs — so the mode menu answered itself and Player-Only could not be reached through the documented
|
||||
install at all. Prompts now read the terminal.
|
||||
|
||||
Pi 5 on Bookworm defaults to Wayland, where `xset`, `unclutter` and `xrandr` are no-ops that log an
|
||||
error and do nothing: those Pis had no blanking suppression and no cursor hiding while appearing
|
||||
configured. The launcher now detects the session and branches. Chromium is told not to ask for a
|
||||
keyring password no kiosk can answer, and the crash-restore surface that put a white page over the
|
||||
player on every boot but the first is cleared properly. The login banner also spelled the product
|
||||
name wrong.
|
||||
|
||||
### Added — a display's IPv6 address on the dashboard
|
||||
The player only ever collected IPv4, so a v6-only panel reported no address at all and the dashboard
|
||||
showed a dash for a perfectly reachable screen. Both are now reported, in their own fields, because
|
||||
a dual-stack panel has both and either may be the one you need. Link-local addresses are excluded —
|
||||
every interface has one and none can be dialled without a zone index.
|
||||
|
||||
### Fixed — the pairing code was unreadable on 4K and 8K panels
|
||||
Every size on the player's setup screens was a hard-coded pixel value. A CSS pixel covers a quarter
|
||||
of the screen area on a 4K panel that it does on 1080p, and a sixteenth on 8K, so the code that
|
||||
fills a 1080p screen was a smudge on the wall it was installed on. Sizing is now proportional to the
|
||||
viewport: identical at 1080p, twice the size at 4K, four times at 8K.
|
||||
|
||||
### Fixed — the screenshot button lied when it could not work
|
||||
The server already answered `offline` or `unsupported`, but no dashboard sender listened, so
|
||||
clicking Screenshot on an offline display showed "Screenshot requested" and did nothing. The verdict
|
||||
now surfaces as a toast. Thanks to @a10kiloham for this and for the thumbnail work above.
|
||||
|
||||
### Fixed — CI judged the capability baselines against the wrong source
|
||||
The baselines describe what an un-updated display can do, so they are checked against the shipped
|
||||
source via a release tag. A shallow checkout has no tags, so the check silently fell back to the
|
||||
working tree — and a release commit made the newest tag HEAD, flipping every assertion at once.
|
||||
Both are fixed; the matrix is judged against the previous release.
|
||||
|
||||
## 1.9.31
|
||||
|
||||
A patch off 1.9.30 carrying the video-wall and playlist-preview work, a QA sweep that drove real
|
||||
browsers and real panels rather than reading code, and the fix for a loop stall our own maintenance
|
||||
was inflicting on a customer's fleet every morning.
|
||||
|
||||
### Fixed — a wall of portrait panels had to be built backwards (#236)
|
||||
The wall canvas was secretly framebuffer space, not the wall as you see it. That is invisible while
|
||||
every panel is the normal way up, and actively misleading the moment one isn't: two portrait-mounted
|
||||
panels standing side by side had to be **stacked vertically** in the editor, with a pre-rotated copy
|
||||
of every video, before the output came out right. It worked, but only after trial and error, and it
|
||||
meant a portrait wall could never reuse existing content.
|
||||
Each panel now carries a mounting rotation (0/90/180/270), the canvas means the physical wall, and
|
||||
the player works out the mapping — so side by side is drawn side by side and landscape content plays
|
||||
across portrait panels unmodified. Applied on the web, Tizen and Android players.
|
||||
**Existing walls are untouched and need no migration.** Every wall in the field is rotation 0, which
|
||||
takes the original code path verbatim — an operator who upgrades will not find a wall that was
|
||||
aligned yesterday has moved. Rebuilding an existing portrait wall the natural way round is an opt-in
|
||||
change the operator makes when they choose to.
|
||||
While a display is a member of a wall, its per-panel rotation replaces its own Orientation setting:
|
||||
the two describe the same physical fact, and honouring both turned the content twice.
|
||||
### Added — a wall no longer hides its own screens (#235)
|
||||
Grouping displays into a wall replaced their individual cards, so one dead panel of a four-panel
|
||||
wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the
|
||||
wall (re-syncing the live wall) and putting it back. The wall screen now lists its panels with live
|
||||
online state and a link straight to each device's page, and the wall card on the dashboard shows a
|
||||
per-member status chip. A screenshot can be requested per panel without disturbing playback.
|
||||
|
||||
### Fixed — the checkpointer was stalling the event loop for seconds at a time (#240)
|
||||
Reported as loop lag that grew with uptime and reset on restart, with a distinctive signature: mean,
|
||||
p50, p99 and max identical to two decimal places. That signature is not a fixed cost paid on every
|
||||
cycle. It is what a `perf_hooks` histogram reports when a window recorded **exactly one** delay —
|
||||
the mean is the raw value and every percentile returns the bucket ceiling above it. Reproducible
|
||||
against the reported figures to the decimal (`record(1329070000)` gives mean 1329.07, p50/p99/max
|
||||
1329.59). So the loop took one long turn that swallowed the whole sampling second, episodically.
|
||||
|
||||
The long turn was ours, and it is measured rather than argued. Running the real checkpointer worker
|
||||
against a real WAL with one reader mid-transaction: a single main-thread write blocked for
|
||||
**4,936 ms**, and the checkpoint that blocked it reported `WAL 8.8MB -> 8.8MB` — it reclaimed
|
||||
nothing. `wal_checkpoint(TRUNCATE)` is the blocking form and its locks are held across
|
||||
*connections*, so moving it to a worker thread in 1.9.2-patch3 took the fsync off the loop but not
|
||||
the lock. It also does not throw when it cannot get those locks: it returns `busy=1` after sitting
|
||||
on SQLite's five-second busy timeout. Five seconds of stalled loop for no benefit, reported as a
|
||||
success.
|
||||
|
||||
It was reached far too easily. The rule was "escalate if the WAL grew across three consecutive 15s
|
||||
runs", which **any sustained 45-second write burst** satisfies — a fleet powering on in the morning
|
||||
does it daily. Escalation now needs the WAL to be in the upper half of its budget
|
||||
(`WAL_CHECKPOINT_STARVATION_FLOOR_MB`, default 8) **and** to be outside a cooldown
|
||||
(`WAL_CHECKPOINT_ESCALATE_COOLDOWN_MS`, default 5 min). Both gates are needed: a size floor alone
|
||||
does nothing for a server whose WAL already sits above it, which was exactly the reported case.
|
||||
|
||||
The 16 MB high-water escalation bypasses both gates and is untouched, so *the WAL still cannot grow
|
||||
unbounded*. A checkpoint that reclaimed nothing now says so in the log instead of reading like a
|
||||
success.
|
||||
|
||||
Also softened the recovery path: when the checkpointer worker is declared unrecoverable, inline
|
||||
autocheckpoint is re-armed on the main connection — a state that lasts the life of the process, and
|
||||
therefore looks exactly like "degrades with uptime, a restart fixes it". It used to also run an
|
||||
unconditional blocking checkpoint on the main thread on the way in; that now happens only above the
|
||||
high-water mark, and the fallback state is served on `/api/status` rather than being inferable only
|
||||
from a log line that may have rolled.
|
||||
|
||||
### Added — loop-lag telemetry that can be read correctly (#240)
|
||||
The reported numbers were interpreted, reasonably, as a per-cycle cost, because nothing in them said
|
||||
how many samples they were made of. `/api/status` now carries `samples` alongside the percentiles
|
||||
(around 50 in a healthy second, 1 when a single turn swallowed it), `tick_gap_ms` measured on the
|
||||
wall clock independently of the histogram, and `worst_tick_gap_ms` / `worst_tick_at` — monotone, so
|
||||
five-minute polling can no longer miss an episode entirely. The `debug` block adds the checkpointer's
|
||||
worker, fallback and respawn state.
|
||||
|
||||
Band semantics are deliberately unchanged: a one-sample window during a real stall is the correct
|
||||
trigger for the shed valve, and suppressing it would blind the protection at the moment it is needed.
|
||||
|
||||
### Fixed — `device_telemetry` grew forever for any display that stopped reporting
|
||||
The only trim was a per-device row cap applied on that device's own heartbeat, so a decommissioned,
|
||||
swapped or seasonally-dark panel left its rows behind permanently. There is now a matching age sweep
|
||||
(`TELEMETRY_RETENTION_DAYS`, default 30), per-device so it rides the existing index rather than
|
||||
scanning, chunked and yielding like the status-log sweep. The default matches the uptime report's own
|
||||
default window, so it cannot remove rows that report would have shown.
|
||||
|
||||
### Added — a playlist preview you can skip through (#239)
|
||||
Reviewing item 8 of a playlist cost seven durations of waiting. The preview takes a skip/next
|
||||
control.
|
||||
|
||||
### Added — a video playlist item defaults to the clip's own length (#237)
|
||||
Rather than the generic default duration, which had to be corrected by hand for every video.
|
||||
|
||||
### Fixed — the dashboard preview of a rotated display (#238)
|
||||
A display rotated 90°/270° was previewed the way its framebuffer is laid out rather than the way
|
||||
people see it. It now matches what the wall shows.
|
||||
|
||||
### Fixed — three controls that did nothing, and a parity matrix that said otherwise
|
||||
An audit of all four players against their shipped sources found controls a customer can press today
|
||||
that change nothing. The volume slider worked on Android only: the dashboard sends
|
||||
`set_volume { level: 0..1 }`, while the web player read `payload.value` and divided by 100 and Tizen
|
||||
read `payload.value ?? payload.volume` — three complete, working volume implementations that could
|
||||
not be driven. Correcting only the key would have been worse than leaving it broken, since
|
||||
`level: 0.5` would have become 0.5%: the scale is now chosen by which key arrived, not by the
|
||||
magnitude of the number.
|
||||
|
||||
Tizen's offline media cache could never have worked on a panel — its adapter used the deprecated
|
||||
Filesystem API in three ways the IDL rules out, so `MediaCache.create()` returned null on every panel
|
||||
in the fleet. BrightSign carried calls that compile and are documented to do something else. Every
|
||||
fix cites the vendor document that proves it, and the linter now fails on each next time.
|
||||
|
||||
Four dashboard→device socket handlers had no capability gate, and a re-register could erase a panel's
|
||||
recorded platform. The parity matrix and the capability baselines are now tested against the players'
|
||||
**shipped** sources in both directions, so a baseline that over-claims and a player that gains a
|
||||
handler without its baseline moving both fail the build.
|
||||
|
||||
### Fixed — a fresh panel skipped the first item of its playlist
|
||||
A newly paired panel always learns its playlist before the media arrives, so the 3-second content
|
||||
re-check is what really begins playback — and it advanced *past* the index already seeded for a
|
||||
playlist that had not started. The first pass ran 1, 2, 3, 0, and item 1 appeared only after the list
|
||||
wrapped. Reproduced on the emulator on every fresh pair.
|
||||
|
||||
### Fixed — a rotated wall panel screenshotted as a black rectangle
|
||||
The mounting rotation introduced with #236 is the first real rotation on an ancestor of the video
|
||||
surface, and the screenshot compositor pasted the frame with an axis-aligned rectangle — so on a
|
||||
rotated panel it landed outside the capture bitmap and the dashboard received plain black. A panel
|
||||
that looks dead while it is playing perfectly is the worst thing a diagnostic can say.
|
||||
|
||||
### Fixed — the service worker claimed credit for offline widgets it never sees
|
||||
`sw.js` said its cache-first widget branch was what kept a widget rendering with the network gone. It
|
||||
is not: the player mounts
|
||||
widgets in an iframe sandboxed without `allow-same-origin`, making it an opaque-origin client that a
|
||||
service worker does not control. Measured, not reasoned — five mounts over 25 seconds of real
|
||||
playback left zero widget entries in the cache.
|
||||
|
||||
### Fixed — CI judged the capability baselines against the wrong source
|
||||
The baselines describe what an un-updated display can do, so they are checked against the shipped
|
||||
source via the latest tag. The default shallow checkout has no tags, so the lookup found nothing and
|
||||
the suite silently fell back to the working tree — where a player's payload bug had just been fixed,
|
||||
making the build demand a baseline change for displays that cannot possibly have the fix yet. Green
|
||||
locally, red in CI, for a reason visible nowhere in the diff. The test job now fetches tags, and the
|
||||
bidirectional assertions skip rather than invert when there are none.
|
||||
|
||||
## 1.9.30
|
||||
|
||||
A patch off 1.9.29 carrying two fixes for faults that are live and silent. Both were found by a QA
|
||||
pass driving real browsers rather than by reading code, and both fail in the direction that leaves a
|
||||
screen dark with nothing in any log.
|
||||
|
||||
### Fixed — a missing media file answered 200 with the dashboard, cached for a month
|
||||
`express.static` calls `next()` on a miss and the only thing downstream was the SPA catch-all, so
|
||||
`GET /uploads/content/<gone>.mp4` returned **200 OK, `Content-Type: text/html`**, 15KB of
|
||||
`index.html`, under the `public, max-age=2592000, immutable` header the mount had already set on the
|
||||
way in.
|
||||
|
||||
For a player that is the worst possible answer. Every downloader in this product treats 200 as
|
||||
success, so a panel stores the HTML page **as the video**, caches it for a month, and renders a black
|
||||
frame. Android's cache validates the byte COUNT against `Content-Length`, not the content type, so a
|
||||
correctly-sized page passes the integrity check and is promoted as a valid asset.
|
||||
|
||||
It is reachable exactly when it hurts: a content replace writes a new randomly-named file and unlinks
|
||||
the old one, so any snapshot still pointing at the old name asks for a file that is gone. A miss now
|
||||
terminates in a 404 with no cache header — `immutable` is a promise about a file that exists.
|
||||
|
||||
### Fixed — an empty playlist wiped a display's entire offline library
|
||||
The player asks the service worker to hold its current media and to drop anything else. An empty list
|
||||
was honoured as "drop everything" — and `assignments: []` is what the server sends for a device
|
||||
between playlists, for a playlist never published, and from inside the `catch` when a stored snapshot
|
||||
fails to parse. Reproduced: three cached assets, one empty payload, cache emptied.
|
||||
|
||||
That is only survivable while the uplink is up, which is precisely when the offline cache does not
|
||||
matter. A cache kept too long costs disk the quota reclaims anyway; one dropped at the wrong moment
|
||||
is a dark screen with no way back. An empty list is no longer a prune instruction.
|
||||
|
||||
## 1.9.29
|
||||
|
||||
The release candidates 1.9.29-rc1 through rc5 are folded in here; the entries below record what
|
||||
changed since 1.9.28 in the form it actually ships. Two of these were found only by driving real
|
||||
hardware and a real browser, and neither could have been caught by a test in this repo.
|
||||
|
||||
### Fixed — the web player's offline cache was switched off at the URL everyone uses
|
||||
A service worker's default scope is its own directory, so `/player/sw.js` could only control
|
||||
`/player/` **and below** — which does not include `/player` itself, the URL the dashboard shows and
|
||||
the one panels are configured with. Registration succeeded, logged success, and then controlled
|
||||
nothing: no shell cache, no content cache, no offline playback, and no error to notice.
|
||||
|
||||
### Fixed — screens went black on a bad link instead of playing cached content
|
||||
The offline playback path was never the problem: the cache could never be **filled**. Every download
|
||||
began at byte 0 and the partial was discarded on any interruption, so an asset larger than one
|
||||
uninterrupted transfer was re-fetched forever. Downloads now resume, with `If-Range` and a 416 guard
|
||||
so a changed or over-long asset can never be spliced.
|
||||
|
||||
### Added — every player caches media for offline playback
|
||||
Tizen caches the media itself now, not just the playlist; the web player (and BrightSign) accumulate
|
||||
in resumable chunks driven by the playlist rather than by playback. Content carries a revision, so
|
||||
replacing an asset reaches displays that already hold the old bytes — previously it could not, ever.
|
||||
|
||||
### Added — players declare what they can actually do
|
||||
Each player reports its real capabilities at registration and the dashboard stops offering controls
|
||||
that cannot work. A display that declares nothing keeps its per-platform baseline, so nothing in the
|
||||
field loses controls on upgrade.
|
||||
|
||||
### Fixed — the BrightSign host scripts were written against Roku's API reference
|
||||
BrightScript is Roku's language and the two references read alike, so calls to objects that do not
|
||||
exist looked exactly like calls to ones that do. A string literal that stopped the script compiling,
|
||||
an existence check that could never return true, and a self-update path that could never mark a
|
||||
package applied — all corrected, and guarded by a checker, since nothing in CI can run BrightScript.
|
||||
|
||||
## 1.9.29-rc5
|
||||
|
||||
### Fixed — the BrightSign host scripts were written against Roku's API reference
|
||||
BrightScript is Roku's language, the two references read almost identically, and nothing in CI can
|
||||
run either — so a call to an object that does not exist looked exactly like a call to one that does.
|
||||
Found by auditing against BrightSign's published reference after a consultant's deployment failed,
|
||||
and verified on an XT245.
|
||||
|
||||
- **A string literal stopped the whole script loading.** `"{""width"":"` is not an escaped quote;
|
||||
BrightScript has no escape sequences, so it is three adjacent literals with no operator between
|
||||
them. The compiler rejects the entire file — `ScriptLoadError: Syntax Error (compile error &h02)`
|
||||
— which is not a broken feature but **no player at all**, on a display showing nothing.
|
||||
- **`MatchFiles` was called with a path as both arguments.** It takes a DIRECTORY plus a pattern and
|
||||
returns nothing when the pattern contains a separator, so the existence check could never return
|
||||
true for any file on any player. That is the reported failure: `no autorun.zip on any volume`
|
||||
printed while `dir SD:` listed it. It also silently disabled the entire self-update path.
|
||||
- **Roku objects that do not exist on BrightSign**, each quietly disabling a feature: `roFileSystem`
|
||||
(~20 sites — an update could never be marked applied), `roMessageDigest` (verification returned
|
||||
false unconditionally and burned the retry counter), `PostFromStringWithRetry` (a snapshot request
|
||||
raised "member function not found" from inside the event loop and took the player down).
|
||||
- **`Unpack()` deletes everything already in its target directory.** Unpacking an update to the
|
||||
volume root would have erased the player's provisioning and its whole content pool as a side
|
||||
effect of a routine upgrade. It now stages to a directory of its own and never overwrites
|
||||
`screentinker.json`.
|
||||
- Rotation moves to `SetScreenModes()` (`SetMode()` takes one argument) and fires only on a real
|
||||
change, because that call reboots the player.
|
||||
|
||||
`server/test/brightscript-api-surface.test.js` guards all of it — a deny-list of Roku APIs plus the
|
||||
argument shapes and literal forms that compile and then do nothing.
|
||||
|
||||
### Fixed — a player that could not cache was telling the fleet it could
|
||||
A real BrightSign exposes `navigator.serviceWorker`, passes an `'serviceWorker' in navigator` check,
|
||||
and then never even fetches the worker: its runtime refuses to register one. It advertised
|
||||
`offline.cache` while unable to cache a byte. The capability is now claimed only when a worker is
|
||||
genuinely in control, and a refused registration reports itself to the server instead of a
|
||||
`console.warn` on a display nobody has a console for.
|
||||
|
||||
### Fixed — storage paths assumed a card slot that may not exist
|
||||
`StorageRoot()` knew only internal flash and SD. Fitting real storage to a flash-booting player and
|
||||
moving the deployment onto it resolved every derived path — the offline page, the widget's local
|
||||
storage, the update paths — to a slot with nothing in it. It now probes in the order the OS itself
|
||||
searches for an autorun script. The widget's `storage_path` is likewise an absolute path on the boot
|
||||
volume rather than a bare `/cache`, which carried no drive specifier and so had nowhere to persist.
|
||||
|
||||
## 1.9.29-rc4
|
||||
|
||||
### Fixed — the web player's offline cache was switched off at the URL everyone uses
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
# No TLS in the image: it listens on plain HTTP :3001. Front it with a
|
||||
# TLS-terminating reverse proxy / Cloudflare in production.
|
||||
|
||||
# --- builder: install production deps (native: better-sqlite3, sharp) ---
|
||||
# --- builder: install production deps (better-sqlite3 is the only native one left; image
|
||||
# decoding is pure JS + WASM since sharp was dropped, and sharp is now a devDependency that
|
||||
# --omit=dev leaves out entirely) ---
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app/server
|
||||
# build toolchain in case a native prebuild is missing for the target arch
|
||||
|
|
@ -18,6 +20,11 @@ RUN npm ci --omit=dev
|
|||
|
||||
# --- runtime ---
|
||||
FROM node:20-slim
|
||||
# ffmpeg (ships ffprobe) powers video thumbnails + duration extraction at upload.
|
||||
# Without it videos still upload and play, but arrive with no thumbnail or duration.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV NODE_ENV=production
|
||||
# Relocate all state onto the volume (config.js reads DATA_DIR; unset would use
|
||||
# the in-repo paths, which we do not want in a container).
|
||||
|
|
|
|||
323
README.md
323
README.md
|
|
@ -114,6 +114,11 @@ self-update — new versions are installed the same way the first one was.
|
|||
- Node.js **20.6+** (the npm scripts use the built-in `--env-file-if-exists` flag, added in 20.6)
|
||||
- Linux, macOS, or Windows
|
||||
- SQLite (bundled via `better-sqlite3`; no separate install needed — `npm install` handles the native bindings)
|
||||
- **ffmpeg** (optional but recommended) — powers video thumbnails and duration extraction
|
||||
(`sudo apt-get install ffmpeg` / `brew install ffmpeg`). Without it, videos upload and
|
||||
play fine but show no thumbnail in the content library. The Docker image includes it.
|
||||
The server logs a `[MEDIA]` line at startup telling you whether it was found, and
|
||||
backfills missing thumbnails automatically once ffmpeg appears after a restart.
|
||||
|
||||
### Quick Start
|
||||
|
||||
|
|
@ -313,31 +318,243 @@ advertising it — put the account on the hidden plan and it simply gets those l
|
|||
above deliberately lists hidden plans too (marked as such), because the previous behaviour was that
|
||||
a hidden plan was invisible to the operator as well as the customer.
|
||||
|
||||
#### Google OAuth
|
||||
#### Single sign-on (OpenID Connect)
|
||||
|
||||
Let users sign in with Google.
|
||||
> **Setting it up?** [**docs/sso-setup.md**](docs/sso-setup.md) is the step-by-step guide — Google and
|
||||
> Microsoft console walkthroughs, per-organization SSO, account linking, and a table of every error
|
||||
> code with its actual cause. The rest of this section is the reference.
|
||||
|
||||
1. Create a project in [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Enable the Google Identity API
|
||||
3. Create OAuth 2.0 credentials (web application)
|
||||
4. Add `https://yourdomain.com` as an authorized origin
|
||||
Any OIDC provider works — Google, Microsoft/Entra, Okta, Auth0, Keycloak, Authentik, Zitadel — through
|
||||
one flow: **Authorization Code with PKCE, run server-side**. The browser never talks to the provider
|
||||
directly, so there is no SDK to load and no third-party script origin to allow in the CSP.
|
||||
|
||||
Every login is verified as an **ID token**: signature against the provider's published JWKS,
|
||||
`iss` exactly as discovered, `aud` (and `azp`) matching your client, `exp`, and a `nonce` this server
|
||||
generated for that specific login. An access token is never accepted as proof of identity.
|
||||
|
||||
Set the redirect URI at your provider to:
|
||||
|
||||
```
|
||||
https://yourdomain.com/api/auth/oidc/<slug>/callback
|
||||
```
|
||||
|
||||
Set `APP_URL` so that origin is pinned — the redirect URI must match your provider's registration
|
||||
exactly, and deriving it from the request `Host` would both break behind a second hostname and take
|
||||
its value from the caller.
|
||||
|
||||
**Google** and **Microsoft** need only the variables this README has always documented; their issuer
|
||||
is filled in for you and their slugs are `google` and `microsoft`:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Your Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_ID` | OAuth 2.0 client ID from [Google Cloud Console](https://console.cloud.google.com) |
|
||||
| `GOOGLE_CLIENT_SECRET` | Optional — PKCE means a public client works |
|
||||
| `MICROSOFT_CLIENT_ID` | Application (client) ID from the [Azure portal](https://portal.azure.com) |
|
||||
| `MICROSOFT_TENANT_ID` | **Your tenant GUID — required.** `common`/`organizations` are refused |
|
||||
| `MICROSOFT_CLIENT_SECRET` | Required in practice — register the redirect URI under the **Web** platform, which Entra treats as a confidential client. A **SPA** registration is rejected at the token endpoint, because this exchange runs server-side and sends no browser `Origin` |
|
||||
|
||||
#### Microsoft OAuth
|
||||
Register the redirect URI under **Web**, add the **`email`** optional claim under *Token configuration →
|
||||
ID*, and note that **Entra ID v2 does not send `email_verified`** — ScreenTinker treats a
|
||||
tenant-pinned Microsoft entry as vouching for the address rather than demanding a claim Microsoft
|
||||
never emits. An explicit `email_verified: false` is still refused, and an organization's own provider
|
||||
can never make that assumption.
|
||||
|
||||
Let users sign in with Microsoft/Azure AD.
|
||||
⚠️ **Multi-tenant Microsoft (`common`) is deliberately refused, and Microsoft sign-in stays disabled
|
||||
until you set a tenant GUID.** Two reasons that point the same way. It cannot work: Microsoft's
|
||||
multi-tenant metadata advertises the literal template `https://login.microsoftonline.com/{tenantid}/v2.0`,
|
||||
so the issuer never matches and every login fails anyway. And the obvious fix is dangerous — accepting
|
||||
that template means accepting tokens from *every* Azure tenant, which is
|
||||
[nOAuth](https://www.descope.com/blog/post/noauth): any tenant admin can set an arbitrary, unverified
|
||||
`email` on one of their own users and be issued a session as that address. Safe multi-tenant support
|
||||
needs per-tenant pinning (validate `tid` against an allowlist, key accounts on `oid`+`tid` rather than
|
||||
email) and is not implemented.
|
||||
|
||||
1. Register an app in [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps)
|
||||
2. Add a web redirect URI: `https://yourdomain.com`
|
||||
3. Note the Application (client) ID
|
||||
**Any other provider** is added by slug:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID |
|
||||
| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) |
|
||||
```bash
|
||||
OIDC_PROVIDERS=okta,authentik
|
||||
OIDC_OKTA_ISSUER=https://example.okta.com
|
||||
OIDC_OKTA_CLIENT_ID=0oa...
|
||||
OIDC_OKTA_NAME=Okta # optional button label
|
||||
OIDC_OKTA_CLIENT_SECRET=... # optional — PKCE means a public client works
|
||||
OIDC_OKTA_SCOPES=openid email profile # optional
|
||||
OIDC_OKTA_ASSUME_EMAIL_VERIFIED=true # only if the IdP verifies addresses but omits the claim
|
||||
```
|
||||
|
||||
The issuer is the base URL whose `/.well-known/openid-configuration` describes the provider; endpoints
|
||||
and keys are discovered from it and cached.
|
||||
|
||||
**Account rules.** A provider must assert a verified email, because the whole account model keys on
|
||||
it. An SSO login never takes over an existing account that has a password — the owner signs in
|
||||
locally and links from Settings. If the provider's stable subject (`sub`) changes for an address, the
|
||||
login is refused rather than handing an account to a recycled mailbox.
|
||||
|
||||
An account established by one provider is **not** adopted by another. A per-organization provider may
|
||||
only claim an account that its own organization established, or a `local` account that has never set
|
||||
a password (an invited user signing in for the first time); anything else is refused with
|
||||
`account_exists_other_provider`. The earlier rule — "any account without a password may be re-pointed
|
||||
at whichever provider spoke last" — was safe only while the operator chose every provider, and became
|
||||
an account-takeover primitive the moment customers could add their own.
|
||||
|
||||
⚠️ **TOTP is not prompted on an SSO login.** Second-factor is the identity provider's job in this
|
||||
flow, matching the long-standing behaviour of the SSO and API-token paths.
|
||||
|
||||
#### Per-organization SSO (customer-configured)
|
||||
|
||||
The providers above are **instance-wide** — they belong to whoever runs the server and appear as
|
||||
buttons on the login page for everyone.
|
||||
|
||||
An organization can also bring **its own** identity provider, configured by an org owner or admin in
|
||||
**Settings → Single sign-on**. No environment variable or restart is involved.
|
||||
|
||||
A per-org provider is **never listed publicly**. It appears only when someone types an email address
|
||||
at one of that organization's **verified** domains, at which point the login page offers a generic
|
||||
"Continue with single sign-on" button. The domain lookup answers only whether that domain uses SSO
|
||||
and whether it is required — never a provider name or slug — so a guessed domain cannot confirm who
|
||||
a customer is, and the mapping back to a provider happens server-side on submit. Both endpoints are
|
||||
rate limited.
|
||||
|
||||
**Instance-wide is the default; an organization overrides only its own verified domains.** Type an
|
||||
address whose domain no organization has verified and you get the local password form plus every
|
||||
instance provider you configured. Type one that an organization has verified and its own button is
|
||||
added — and if that organization requires SSO, it becomes the only option.
|
||||
|
||||
Each provider gets a randomly generated redirect URI, shown in Settings, which the admin registers
|
||||
with their identity provider:
|
||||
|
||||
```
|
||||
https://yourdomain.com/api/auth/oidc/<generated-slug>/callback
|
||||
```
|
||||
|
||||
The slug is generated rather than chosen so two customers cannot collide on — or guess — each
|
||||
other's. A domain may be claimed by only one organization; a second claim is refused.
|
||||
|
||||
A customer bringing **Microsoft/Entra** registers a single-tenant application in their own directory
|
||||
and uses `https://login.microsoftonline.com/<their-tenant-guid>/v2.0` as the issuer. Because Entra
|
||||
does not send `email_verified`, an organization's provider is trusted to assert addresses **once it
|
||||
has verified a domain** — the DNS proof is what stands in for the claim, and the provider is confined
|
||||
to those domains regardless. A provider that has verified nothing assumes nothing, and an explicit
|
||||
`email_verified: false` is refused whoever sends it.
|
||||
|
||||
⚠️ **A provider may only authenticate emails inside the domains it has VERIFIED.** An organization
|
||||
supplies its own issuer and client ID, so it controls that identity provider completely and could
|
||||
otherwise assert any address at all — including another company's, or an administrator's. Confining
|
||||
assertions to verified domains is what makes customer-configurable SSO safe to offer.
|
||||
|
||||
⚠️ **Public email providers cannot be claimed.** `gmail.com`, `outlook.com`, `yahoo.com`, `icloud.com`
|
||||
and the rest of the consumer mailboxes are refused (`server/lib/public-email-domains.js`). Claiming
|
||||
one would offer every Gmail user a "sign in with your organization" button pointing at one tenant's
|
||||
infrastructure — phishing launched from this product's own login page — and would let one account
|
||||
deny a public domain to everyone else.
|
||||
|
||||
##### Proving a domain
|
||||
|
||||
A claimed domain **routes nobody and authenticates nobody until DNS proves the organization controls
|
||||
it.** Typing a domain into a form reserves the name and nothing more.
|
||||
|
||||
Publish this record, then press **Verify**:
|
||||
|
||||
```
|
||||
_screentinker-verify.example.com. IN TXT "st-verify=<token>"
|
||||
```
|
||||
|
||||
The token is unique per domain, so publishing one proof cannot be replayed to claim a second. A
|
||||
dedicated `_`-prefixed name is used rather than the apex, where a careless edit would sit alongside
|
||||
SPF and DMARC and break mail — and where a wildcard `*.example.com` could not be confused for a
|
||||
proof, since a wildcard answers with its own value and never with the token.
|
||||
|
||||
TXT is the only accepted form. A CNAME alternative would have to point at a wildcard zone this
|
||||
project operates, answering for every token ever issued; documenting one without running it would
|
||||
describe a check that can never pass.
|
||||
|
||||
⚠️ **The proof name itself must not be a CNAME.** A TXT lookup follows CNAMEs, and a wildcard
|
||||
`*.example.com` covers `_screentinker-verify.example.com` too — so a wildcard CNAME would let
|
||||
whoever controls its target prove the domain, turning an ordinary subdomain takeover into control of
|
||||
every `@example.com` login. A delegated proof name is refused, which is stricter than ACME's dns-01.
|
||||
|
||||
**An unverified claim lapses after 8 hours, and lapsing RELEASES it.** Pressing Verify on an
|
||||
expired claim does not reissue it in place — that renewed the clock, so one request per window held
|
||||
a domain forever. The claim is released, the domain becomes free for anyone else, and re-adding it
|
||||
is a new claim: new token, and the operator is notified again. A verified domain never expires;
|
||||
re-proving on a timer would log a customer out over a DNS edit made months afterwards. Squatting is
|
||||
not made impossible — it is made loud.
|
||||
|
||||
**Deleting a provider releases its domains and returns its accounts to local sign-in**, so the
|
||||
organization can re-claim its own domain and its people can recover by password reset. Both used to
|
||||
be stranded: a verified domain row outlived its provider and blocked that domain for everyone
|
||||
permanently, and its users could neither sign in nor reset.
|
||||
|
||||
Platform admins are emailed whenever a domain is claimed. Verification is what makes an unowned
|
||||
claim worthless; the notification is what makes an attempt visible. Nothing is ever sent to the
|
||||
claimed domain itself — that would let any tenant make this product email third parties.
|
||||
|
||||
⚠️ **Instance-wide providers are exempt from all of the above.** `GOOGLE_CLIENT_ID`, `OIDC_*` and
|
||||
friends are the operator's own configuration, are not domain-restricted, and require no verification.
|
||||
Domain proof exists because per-organization providers are supplied by CUSTOMERS.
|
||||
|
||||
Signing in through an organization's provider makes the user a member of that organization
|
||||
(`org_member`). Existing members keep whatever role they already have — logging in never promotes or
|
||||
demotes anyone. Client secrets are optional (PKCE), and are stored AES-256-GCM encrypted and never
|
||||
returned by the API.
|
||||
|
||||
##### Requiring single sign-on
|
||||
|
||||
An organization can turn off password sign-in for its verified domains, so its identity provider is
|
||||
the only way in — which is the point of buying SSO: the IdP holds the MFA, the conditional access
|
||||
and the instant removal of access, and a password box beside it is a way around all three.
|
||||
|
||||
Settings → Single sign-on → **Require single sign-on**. It needs at least one verified domain, so an
|
||||
organization cannot leave its own people with no way to sign in, and cannot switch off passwords for
|
||||
a domain it merely typed.
|
||||
|
||||
When it is on:
|
||||
|
||||
- the login page **hides** the password field for those domains rather than letting someone type a
|
||||
password that is going to be refused and then send them to reset it;
|
||||
- `POST /api/auth/login` refuses with `403 sso_required` — distinguishable from a wrong password,
|
||||
because the page must not tell a user to fix a credential that is not the problem;
|
||||
- **every other identity provider is refused too**, including the instance's own Google or
|
||||
Microsoft. Those belong to the operator and are not domain-restricted, so leaving them available
|
||||
would be a side door straight past the customer's MFA — blocking passwords while leaving
|
||||
"Continue with Google" is not requiring single sign-on, it is renaming the bypass.
|
||||
|
||||
**Turning it off is a request, not a switch.** That direction re-opens password sign-in, so it is
|
||||
the direction an attacker who has taken an org admin would take, and it is also what a customer will
|
||||
demand at their worst moment — identity provider down, nobody can work — which is exactly when a
|
||||
self-service toggle gets flipped without thinking. The org admin files a request; a **platform admin
|
||||
approves it**, and nothing changes until they do.
|
||||
|
||||
The approval email deliberately carries **no action link**. A token that acts on its own would turn
|
||||
every forwarded, archived or auto-previewed copy of that message into a way to switch off a
|
||||
customer's single sign-on. The decision is made signed in, under Admin.
|
||||
|
||||
⚠️ **`platform_admin` is exempt from enforcement, and that exemption is load-bearing.** The operator
|
||||
is who approves removal. If the operator's own address sat at an SSO-only domain and that identity
|
||||
provider broke, nobody could sign in to approve anything and the instance would be bricked with no
|
||||
way out. It is the break-glass — it applies to the people running the server, never to a customer's
|
||||
own admins.
|
||||
|
||||
⚠️ **This makes the approval queue an availability dependency.** An organization whose IdP breaks is
|
||||
locked out until an operator acts. That is the intended trade — deliberate friction on the dangerous
|
||||
direction — but it should be a decision, not a surprise.
|
||||
|
||||
#### Dependency preflight on boot
|
||||
|
||||
Before anything else is loaded, the server checks that the packages this build declares are actually
|
||||
installed and that the native database module loads under the running Node. If either is wrong it
|
||||
repairs it (`npm install --omit=dev`, or `npm rebuild better-sqlite3`) and continues; if it cannot,
|
||||
it exits saying what to run rather than dying on a `MODULE_NOT_FOUND` naming a file.
|
||||
|
||||
`scripts/upgrade.sh` already installs dependencies, so this is not for the normal path. It is for
|
||||
the ways a box ends up with the wrong `node_modules`:
|
||||
|
||||
- **rolling back** to an older tag restores that tag's `package.json` but not its packages — and you
|
||||
are rolling back because something is already wrong;
|
||||
- **upgrading Node** leaves `better-sqlite3` compiled against the previous ABI, which fails in a way
|
||||
that reads like database corruption and is not.
|
||||
|
||||
Set `ST_SKIP_DEP_PREFLIGHT=1` on an air-gapped host, or anywhere you manage `node_modules` yourself
|
||||
and do not want a boot reaching for the registry.
|
||||
|
||||
#### Email (Microsoft Graph or SMTP)
|
||||
|
||||
|
|
@ -413,6 +630,10 @@ Use this in local dev when running against a fresh production database clone to
|
|||
- **Sequential send pattern** through the offline-alert backlog — avoids Graph's per-app concurrent-send throttling (HTTP 429 `ApplicationThrottled`)
|
||||
- **Per-user opt-out** via the `email_alerts` toggle in Settings → Account; respects user preference before any Graph call
|
||||
|
||||
> **Running one day to day?** [**docs/operations.md**](docs/operations.md) is the runbook —
|
||||
> deploy and rollback for both shapes, how to verify a deploy actually took, the served-APK rules,
|
||||
> and the traps that have cost real time.
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production, put the app behind a reverse proxy (nginx, Caddy, etc.) with SSL:
|
||||
|
|
@ -425,7 +646,8 @@ sudo useradd -r -s /bin/false screentinker
|
|||
sudo cp -r . /opt/screentinker
|
||||
sudo chown -R screentinker:screentinker /opt/screentinker
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (ffmpeg is for video thumbnails + durations — see Requirements)
|
||||
sudo apt-get install -y ffmpeg
|
||||
cd /opt/screentinker/server && npm install --production
|
||||
|
||||
# Create a systemd service
|
||||
|
|
@ -499,6 +721,28 @@ server {
|
|||
}
|
||||
```
|
||||
|
||||
#### Don't add security headers at the proxy
|
||||
|
||||
The app already sets `X-Frame-Options`, `Strict-Transport-Security`, `Content-Security-Policy`,
|
||||
`X-Content-Type-Options`, etc. via [helmet](https://helmetjs.github.io/), and manages them
|
||||
**per route**: widget/kiosk renders and the device preview deliberately remove or relax
|
||||
`X-Frame-Options` so they can be framed, while the dashboard keeps the strict policy.
|
||||
|
||||
A proxy-level header block (nginx `add_header X-Frame-Options DENY;`, a Caddy
|
||||
`header { ... }` snippet, or a "security headers" preset) *adds a second copy* of these
|
||||
headers on top of the app's. Browsers treat conflicting duplicate `X-Frame-Options`
|
||||
values as `deny`, which breaks the dashboard's device Preview (a same-origin iframe of
|
||||
`/player`) and widget previews with console errors like:
|
||||
|
||||
```
|
||||
Refused to display 'https://…' in a frame because it set multiple
|
||||
'X-Frame-Options' headers with conflicting values ('DENY, SAMEORIGIN').
|
||||
Falling back to 'deny'.
|
||||
```
|
||||
|
||||
Let the proxy handle TLS, compression, and body-size limits only, and leave security
|
||||
headers to the app.
|
||||
|
||||
### Updating
|
||||
|
||||
To update a running instance to the latest version:
|
||||
|
|
@ -634,13 +878,56 @@ keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -
|
|||
2. Go to **Displays** and click **Add Display**
|
||||
3. Install the ScreenTinker app on your device:
|
||||
- **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above)
|
||||
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash`
|
||||
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | sudo bash` (see [Raspberry Pi notes](#raspberry-pi-notes))
|
||||
- **Debian 13 (headless)**: `curl -sSL https://your-instance/scripts/debian-13-setup.sh | sudo bash`
|
||||
- **Windows**: Run the setup script from `scripts/windows-setup.bat`
|
||||
- **Samsung Tizen TV / signage**: point the TV's URL Launcher (or browser) at `https://your-instance/player` - no signing needed. For an installed native app, see [tizen/README.md](tizen/README.md)
|
||||
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
|
||||
4. Enter the pairing code shown on the device
|
||||
|
||||
### Raspberry Pi notes
|
||||
|
||||
**Run it with `sudo`.** The script installs packages and writes systemd units, so it refuses to
|
||||
run otherwise. Piping is fine — prompts are read from your terminal, not from the pipe:
|
||||
|
||||
```bash
|
||||
curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | sudo bash
|
||||
```
|
||||
|
||||
To pick Player-Only without being asked:
|
||||
|
||||
```bash
|
||||
curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://your-server
|
||||
```
|
||||
|
||||
**Pi 5 / Bookworm runs Wayland by default.** The kiosk launcher detects the session and does the
|
||||
right thing on either: `xset`/`unclutter` are X11-only and are skipped on Wayland (where they are
|
||||
no-ops that log an error and silently do nothing), Chromium is given `--ozone-platform=wayland`,
|
||||
and `--password-store=basic` stops it asking for a keyring password no kiosk has anyone to answer.
|
||||
|
||||
Blanking and cursor-hiding belong to the compositor on Wayland. The launcher calls `wlopm` when it
|
||||
is present; if your image does not ship it, set the equivalent in your compositor's config
|
||||
(`~/.config/wayfire.ini` `[idle]` for wayfire, or the labwc equivalent).
|
||||
|
||||
**A white page on every boot but the first** was Chromium restoring a session it believed crashed —
|
||||
a kiosk is killed by shutdown and never exits cleanly, so it came back with a restore surface on
|
||||
top of the player. The launcher now clears the stored session as well as the clean-exit flag.
|
||||
|
||||
#### Read-only root (Overlay FS) on a Pi that loses power
|
||||
|
||||
Worth enabling for **Player-Only** installs, where the Pi holds no state you cannot recreate: the
|
||||
overlay absorbs writes into RAM, so a power cut cannot corrupt the card and the flash does not wear
|
||||
out. Re-run the setup script (or `raspi-config` → Performance → Overlay File System) *after* the
|
||||
install, and remember that pairing is stored on the device — re-pair once with the overlay
|
||||
disabled, then enable it, or the pairing is lost at every reboot.
|
||||
|
||||
**Do not enable it on an All-in-One install without moving the data first.** That Pi *is* the
|
||||
server: the SQLite database, uploaded media and the JWT secret live under `/opt/screentinker`, and
|
||||
an overlay discards every write at reboot — so content you upload and displays you pair vanish on
|
||||
the next power cycle. If you want both, put `DATA_DIR` on a writable partition or an external
|
||||
drive that is excluded from the overlay, and confirm the database file is genuinely outside it
|
||||
before trusting the setup.
|
||||
|
||||
On the Android player, the setup screen lists the permissions it wants and lets you revisit any of
|
||||
them later — each row stays visible once granted and turns into **Manage**, so you can check or
|
||||
revoke what you gave it rather than having the option disappear.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ android {
|
|||
targetSdk = 34
|
||||
// Env-overridable so device-owner reinstalls (which require an ever-increasing
|
||||
// versionCode — downgrades are blocked) don't churn this file each build.
|
||||
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "101").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.29-rc4"
|
||||
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "123").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.36"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
@ -87,8 +87,24 @@ dependencies {
|
|||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
|
||||
// Socket.IO client
|
||||
implementation("io.socket:socket.io-client:2.1.0")
|
||||
// Socket.IO client.
|
||||
//
|
||||
// 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
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
|
|
|
|||
|
|
@ -149,6 +149,27 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
// Fullscreen immersive
|
||||
@Suppress("DEPRECATION")
|
||||
/*
|
||||
* Ask for a full-bleed window through BOTH APIs.
|
||||
*
|
||||
* systemUiVisibility has been deprecated since API 30 and some OEM builds honour it only
|
||||
* partially: `dumpsys window` on one RK356x box reported `init=1920x1080 app=1920x1024`,
|
||||
* i.e. the firmware kept reserving 56px for a navigation bar that was set to hide, so the
|
||||
* app was never given those pixels to paint. WindowCompat is the supported route on those
|
||||
* builds. Both are set because neither is reliable alone across signage hardware: the
|
||||
* legacy flags still carry older devices, the compat API carries newer and OEM ones.
|
||||
*
|
||||
* Verify per device rather than assume — `adb shell dumpsys window displays` should show
|
||||
* app= equal to init=. If it still does not, the reservation is a firmware behaviour no
|
||||
* app-side call can override and it has to be turned off on the device.
|
||||
*/
|
||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
androidx.core.view.WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior =
|
||||
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
|
|
@ -370,12 +391,57 @@ class MainActivity : AppCompatActivity() {
|
|||
// (playerView/imageView/youtubeWebView) and multi-zone (ZoneManager renders into
|
||||
// the same rootView). Values mirror the dashboard: landscape / portrait /
|
||||
// landscape-flipped / portrait-flipped.
|
||||
private fun applyOrientation(orientation: String) {
|
||||
if (orientation == currentOrientation) return
|
||||
currentOrientation = orientation
|
||||
/**
|
||||
* The size of the WINDOW we are allowed to paint, measured NOW.
|
||||
*
|
||||
* ⚠️ Deliberately the window and not the display. On a box whose firmware keeps reserving space
|
||||
* for a system bar, `dumpsys window` reports e.g. `init=1920x1080 app=1920x1024`: the panel is
|
||||
* 1080 tall but the window is 56px shorter, and those 56px are simply not ours to draw in.
|
||||
* Sizing the stage to the DISPLAY there would not fill the gap — it would push the bottom of
|
||||
* every asset outside the window and silently crop it, which is worse than a border.
|
||||
*
|
||||
* The original defect was not which size was read but WHEN: `resources.displayMetrics` was read
|
||||
* once, while a bar was still on screen, and written into rootView's layoutParams for good.
|
||||
* Immersive mode is a request — bars hide and the window grows a few frames later — so a
|
||||
* playlist arriving first froze the stage at bar-sized dimensions, leaving dead space exactly
|
||||
* the size of a bar that had since vanished. It looked random because it was a race, and
|
||||
* rendering the cached playlist immediately at boot made losing it the common case.
|
||||
*
|
||||
* So: read it late, read it again whenever the window changes, and never cache it across a
|
||||
* window resize. See reapplyOrientation().
|
||||
*/
|
||||
private fun windowSize(): Pair<Float, Float> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val b = windowManager.currentWindowMetrics.bounds
|
||||
return b.width().toFloat() to b.height().toFloat()
|
||||
}
|
||||
val m = resources.displayMetrics
|
||||
val w = m.widthPixels.toFloat()
|
||||
val h = m.heightPixels.toFloat()
|
||||
return m.widthPixels.toFloat() to m.heightPixels.toFloat()
|
||||
}
|
||||
|
||||
/** Stage size last applied, so a stage sized during a transient window state can heal. */
|
||||
private var appliedStageW = 0f
|
||||
private var appliedStageH = 0f
|
||||
|
||||
/**
|
||||
* Re-run the current orientation against the panel size as it is NOW.
|
||||
*
|
||||
* Called when the window settles (focus regained, bars finally hidden). Without this, a stage
|
||||
* measured too early is permanent: applyOrientation() returns immediately when the orientation
|
||||
* string has not changed, and it never changes on a display that has always been landscape.
|
||||
*/
|
||||
private fun reapplyOrientation() {
|
||||
applyOrientation(currentOrientation ?: "landscape")
|
||||
}
|
||||
|
||||
private fun applyOrientation(orientation: String) {
|
||||
val (w, h) = windowSize()
|
||||
// The guard compares the measured SIZE as well as the orientation. Comparing the string
|
||||
// alone is what made a bad measurement unrecoverable.
|
||||
if (orientation == currentOrientation && w == appliedStageW && h == appliedStageH) return
|
||||
currentOrientation = orientation
|
||||
appliedStageW = w
|
||||
appliedStageH = h
|
||||
val (rot, swap) = when (orientation) {
|
||||
"portrait" -> 90f to true
|
||||
"portrait-flipped" -> 270f to true
|
||||
|
|
@ -447,8 +513,17 @@ class MainActivity : AppCompatActivity() {
|
|||
// Video-wall slice transform. The content view represents the whole wall (player_rect);
|
||||
// size + offset rootView so this screen's screen_rect fills the device viewport, content
|
||||
// stretched to fill (object-fit:fill parity, set on the views via MediaPlayerManager).
|
||||
// Mirrors the web player's vw/vh stage math. Per-tile rotation is intentionally not
|
||||
// applied (web/Tizen parity). cfg == null restores full screen.
|
||||
// Mirrors the web player's vw/vh stage math. cfg == null restores full screen.
|
||||
//
|
||||
// #236: per-panel mounting rotation is now applied. Ported by hand from
|
||||
// server/lib/wall-geometry.js, which is the canonical rule and the only place it is tested —
|
||||
// Kotlin cannot load the shared script the web player pulls, so any change there has to be
|
||||
// mirrored here or a wall of mixed players grows a seam. `rotation` is degrees CLOCKWISE the
|
||||
// content is turned inside the framebuffer (the same convention as the orientation setting),
|
||||
// and Android's View.rotation is clockwise-positive too, so it maps straight across.
|
||||
//
|
||||
// Transform order matters: Android rotates about the pivot (view centre) and THEN applies
|
||||
// translation, so the translation is computed to land the view's CENTRE, not its top-left.
|
||||
private fun applyWallTransform(cfg: WallController.WallConfig?) {
|
||||
val lp = rootView.layoutParams
|
||||
if (cfg == null) {
|
||||
|
|
@ -476,19 +551,49 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
val dw = resources.displayMetrics.widthPixels.toFloat()
|
||||
val dh = resources.displayMetrics.heightPixels.toFloat()
|
||||
lp.width = ((p.w / s.w) * dw).toInt()
|
||||
lp.height = ((p.h / s.h) * dh).toInt()
|
||||
rootView.layoutParams = lp
|
||||
rootView.translationX = ((p.x - s.x) / s.w) * dw // negative for right/lower tiles
|
||||
rootView.translationY = ((p.y - s.y) / s.h) * dh
|
||||
rootView.rotation = 0f // per-tile rotation: TODO (parity = none)
|
||||
val rot = when (cfg.rotation) { 90 -> 90; 180 -> 180; 270 -> 270; else -> 0 }
|
||||
|
||||
if (rot == 0) {
|
||||
// Left byte-identical to the pre-#236 expression on purpose: every wall in the field is
|
||||
// rotation 0, and an operator who updates must not find a wall that was aligned
|
||||
// yesterday has shifted by a rounding error.
|
||||
lp.width = ((p.w / s.w) * dw).toInt()
|
||||
lp.height = ((p.h / s.h) * dh).toInt()
|
||||
rootView.layoutParams = lp
|
||||
rootView.translationX = ((p.x - s.x) / s.w) * dw // negative for right/lower tiles
|
||||
rootView.translationY = ((p.y - s.y) / s.h) * dh
|
||||
rootView.rotation = 0f
|
||||
} else {
|
||||
// A quarter turn measures the wall's horizontal against the framebuffer's VERTICAL —
|
||||
// on a panel hung sideways, moving right along the wall moves down the display.
|
||||
val quarter = (rot == 90 || rot == 270)
|
||||
val boxW = (p.w / s.w) * (if (quarter) dh else dw)
|
||||
val boxH = (p.h / s.h) * (if (quarter) dw else dh)
|
||||
// Where the player rect's centre sits within this panel's rect, 0..1 in wall space.
|
||||
val nx = (p.x + p.w / 2f - s.x) / s.w
|
||||
val ny = (p.y + p.h / 2f - s.y) / s.h
|
||||
// ...and where that lands in the framebuffer once the panel's turn is undone.
|
||||
val cx: Float
|
||||
val cy: Float
|
||||
when (rot) {
|
||||
90 -> { cx = 1f - ny; cy = nx }
|
||||
180 -> { cx = 1f - nx; cy = 1f - ny }
|
||||
else -> { cx = ny; cy = 1f - nx } // 270
|
||||
}
|
||||
lp.width = boxW.toInt()
|
||||
lp.height = boxH.toInt()
|
||||
rootView.layoutParams = lp
|
||||
rootView.rotation = rot.toFloat()
|
||||
rootView.translationX = cx * dw - boxW / 2f
|
||||
rootView.translationY = cy * dh - boxH / 2f
|
||||
}
|
||||
rootView.scaleX = 1f
|
||||
rootView.scaleY = 1f
|
||||
rootView.requestLayout()
|
||||
mirrorTransformToPip()
|
||||
// Orientation no longer reflects reality; ensure it re-applies after wall exit.
|
||||
currentOrientation = null
|
||||
Log.i("MainActivity", "Wall transform: size=${lp.width}x${lp.height} tx=${rootView.translationX} ty=${rootView.translationY}")
|
||||
Log.i("MainActivity", "Wall transform: size=${lp.width}x${lp.height} tx=${rootView.translationX} ty=${rootView.translationY} rot=$rot")
|
||||
}
|
||||
|
||||
private fun setupServiceCallbacks() {
|
||||
|
|
@ -836,6 +941,14 @@ class MainActivity : AppCompatActivity() {
|
|||
if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true)
|
||||
}
|
||||
// #161 device-owner tooling: push + silently install an arbitrary APK from a URL.
|
||||
// Escape hatch for a panel holding a stale/bad staged APK: drop every cached file
|
||||
// so the next check downloads afresh. Only ever deletes caches.
|
||||
"clear_update_cache" -> {
|
||||
if (::updateChecker.isInitialized) {
|
||||
val n = updateChecker.clearUpdateCache()
|
||||
Log.i("MainActivity", "clear_update_cache removed $n file(s)")
|
||||
}
|
||||
}
|
||||
"install_apk" -> {
|
||||
val url = payload?.optString("url", "") ?: ""
|
||||
if (url.isNotBlank() && ::updateChecker.isInitialized) {
|
||||
|
|
@ -1472,6 +1585,16 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
/*
|
||||
* Re-measure the stage once the window has settled.
|
||||
*
|
||||
* Hiding the bars is asynchronous, so the window is often still bar-sized when the
|
||||
* first playlist arrives and sizes the stage. Without this the mistake is permanent:
|
||||
* applyOrientation() used to return immediately whenever the orientation string was
|
||||
* unchanged, and it never changes on a display that has always been landscape. Posting
|
||||
* it runs after this layout pass, when the window is whatever it is finally going to be.
|
||||
*/
|
||||
rootView.post { reapplyOrientation() }
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
|
|
|
|||
|
|
@ -544,7 +544,10 @@ class PlaylistController(
|
|||
retryRunnable = Runnable {
|
||||
if (isRunning && items.isNotEmpty()) {
|
||||
if (firstActiveIndex() < 0) { showNothingScheduled(); return@Runnable }
|
||||
val idx = PlaylistSelection.nextPlayableIndex(items.size, currentIndex) { playableNow(it) }
|
||||
// Include currentIndex when nothing is on screen: there it is the intended START,
|
||||
// not a position that has had its turn. Skipping it dropped item 1 on every cold
|
||||
// start, because a fresh panel always gets the playlist before the media.
|
||||
val idx = PlaylistSelection.recheckIndex(items.size, currentIndex, hasContentOnScreen) { playableNow(it) }
|
||||
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,39 @@ object PlaylistSelection {
|
|||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* First playable index AT OR AFTER [from] (wrapping), or -1 if none. A negative [from] means
|
||||
* "no position yet" and starts at 0 rather than wrapping onto the last item.
|
||||
*/
|
||||
fun playableFromIndex(size: Int, from: Int, isPlayable: (Int) -> Boolean): Int {
|
||||
if (size <= 0) return -1
|
||||
val start = if (from < 0) 0 else from % size
|
||||
for (i in 0 until size) {
|
||||
val idx = (start + i) % size
|
||||
if (isPlayable(idx)) return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Which item a content re-check should play once something finally becomes ready.
|
||||
*
|
||||
* [hasContentOnScreen] is the entire distinction, and getting it wrong costs the operator the
|
||||
* first item of their playlist. When content IS up, currentIndex is a real position that has
|
||||
* already had its turn, so the scan must move PAST it. When nothing is up, currentIndex is only
|
||||
* where playback INTENDED to begin — updatePlaylist() seeds it to 0 for a playlist that has not
|
||||
* started yet — so it has never been shown, and advancing past it silently drops item 1 from the
|
||||
* first pass through the playlist.
|
||||
*
|
||||
* That is the cold-start case on every fresh panel: the playlist arrives before its media has
|
||||
* downloaded, start() finds nothing playable, and the re-check three seconds later is what
|
||||
* actually begins playback. On a two-item playlist it looks exactly like "only one of the two
|
||||
* ever plays" until the list wraps.
|
||||
*/
|
||||
fun recheckIndex(size: Int, from: Int, hasContentOnScreen: Boolean, isPlayable: (Int) -> Boolean): Int =
|
||||
if (hasContentOnScreen) nextPlayableIndex(size, from, isPlayable)
|
||||
else playableFromIndex(size, from, isPlayable)
|
||||
|
||||
enum class NonePlayable { KEEP_CURRENT, SHOW_WAITING }
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -66,16 +66,25 @@ class ScreenshotCapture {
|
|||
if (tv.isAvailable && tv.visibility == View.VISIBLE) {
|
||||
val tvBitmap = tv.bitmap
|
||||
if (tvBitmap != null) {
|
||||
val loc = IntArray(2)
|
||||
tv.getLocationInWindow(loc)
|
||||
val rootLoc = IntArray(2)
|
||||
view.getLocationInWindow(rootLoc)
|
||||
val x = (loc[0] - rootLoc[0]).toFloat()
|
||||
val y = (loc[1] - rootLoc[1]).toFloat()
|
||||
val destRect = Rect(x.toInt(), y.toInt(), x.toInt() + tv.width, y.toInt() + tv.height)
|
||||
canvas.drawBitmap(tvBitmap, null, destRect, null)
|
||||
// Place the frame through the SAME transform chain the hierarchy was drawn
|
||||
// with, rather than an axis-aligned rect at getLocationInWindow().
|
||||
//
|
||||
// #236 gave a video-wall panel a mounting rotation, which puts a real
|
||||
// rotation on an ancestor of this TextureView. An axis-aligned rect cannot
|
||||
// express that, so the frame was pasted un-rotated at a position that fell
|
||||
// outside the capture bitmap entirely — and what the dashboard received was
|
||||
// the plain black that view.draw() leaves wherever a TextureView is, i.e. a
|
||||
// panel that looks dead while it is happily playing. Verified on the
|
||||
// emulator: at rotation 90 every remote screenshot of a video came back
|
||||
// #010101 with zero variance, while rotation 0 was correct.
|
||||
val m = matrixTo(tv, view)
|
||||
// The surface bitmap is not required to match the view's size.
|
||||
if (tvBitmap.width > 0 && tvBitmap.height > 0) {
|
||||
m.preScale(tv.width.toFloat() / tvBitmap.width, tv.height.toFloat() / tvBitmap.height)
|
||||
}
|
||||
canvas.drawBitmap(tvBitmap, m, null)
|
||||
tvBitmap.recycle()
|
||||
Log.d("ScreenshotCapture", "Composited TextureView at ($x,$y) size=${tv.width}x${tv.height}")
|
||||
Log.d("ScreenshotCapture", "Composited TextureView ${tv.width}x${tv.height} via $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -114,6 +123,28 @@ class ScreenshotCapture {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The matrix mapping [view]'s own coordinates into [ancestor]'s, by walking up the parent chain
|
||||
* and concatenating each step the way the framework does when it draws a child: the child's own
|
||||
* matrix (rotation/scale/translation about its pivot) followed by its layout offset.
|
||||
*
|
||||
* Stops at [ancestor], or at the top of the View chain if it is never reached — a partial chain
|
||||
* still places the frame better than ignoring the transform completely.
|
||||
*/
|
||||
private fun matrixTo(view: View, ancestor: View): android.graphics.Matrix {
|
||||
val out = android.graphics.Matrix()
|
||||
var v: View = view
|
||||
while (true) {
|
||||
val local = android.graphics.Matrix(v.matrix) // translationX/Y + rotation about pivot
|
||||
local.postTranslate(v.left.toFloat(), v.top.toFloat())
|
||||
out.postConcat(local) // out = local * out (child-first)
|
||||
val parent = v.parent
|
||||
if (parent !is View || parent === ancestor) break
|
||||
v = parent
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun findAllTextureViews(view: View, result: MutableList<TextureView>) {
|
||||
if (view is TextureView) {
|
||||
result.add(view)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,18 @@ class UpdateChecker(private val context: Context) {
|
|||
// class is the imperative shell that persists state and does the download/install.
|
||||
var otaLogReporter: ((level: String, message: String) -> Unit)? = null
|
||||
|
||||
/*
|
||||
* Why the last download/verify attempt failed, in specific terms.
|
||||
*
|
||||
* The caller could only ever say "failed to download or failed signature verification", which
|
||||
* covers SEVEN distinct branches — three of them download failures where verification never
|
||||
* runs at all. Every specific reason went to logcat, which an unprivileged app UID cannot read
|
||||
* on Android 9, so in the field the message was unactionable: it named a symptom shared by
|
||||
* unrelated causes and pointed at the wrong half of the code as often as the right one.
|
||||
* Diagnosing one occurrence took an evening. This makes the next one a sentence.
|
||||
*/
|
||||
private var lastFailure: String? = null
|
||||
|
||||
private fun report(level: String, message: String) {
|
||||
when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) }
|
||||
try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {}
|
||||
|
|
@ -272,7 +284,7 @@ class UpdateChecker(private val context: Context) {
|
|||
// Unforced this is deliberately quiet (transient network blips are not news). Forced,
|
||||
// somebody is waiting on an answer, and "the APK would not download or did not match
|
||||
// our signing key" is the single most useful thing we can tell them.
|
||||
if (forced) report("error", "Force update: $latestVersion failed to download or failed signature verification — not installed")
|
||||
if (forced) report("error", "Force update: $latestVersion not installed — ${lastFailure ?: "reason unavailable"}")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -311,15 +323,99 @@ class UpdateChecker(private val context: Context) {
|
|||
// Returns TRUE only when a verified APK is in hand and an install has been launched (the
|
||||
// caller may then count an attempt); FALSE on any download/verify failure — the caller must
|
||||
// NOT count those, so a transient network problem can't burn a healthy device's budget. #139
|
||||
/*
|
||||
* Where a downloaded APK is staged.
|
||||
*
|
||||
* getExternalFilesDir() returns NULL whenever external storage is not mounted/available — and
|
||||
* on a signage panel that is not exotic: no emulated volume, a vendor ROM that never mounts one,
|
||||
* an SD card ejected, storage still unmounted early in boot.
|
||||
*
|
||||
* The bug this replaces: `File(context.getExternalFilesDir(...), name)`. Java's File(File,String)
|
||||
* treats a NULL parent as "no parent" and silently produces a RELATIVE path, so the download
|
||||
* targeted `ScreenTinker-x.y.z.apk` in the process working directory — `/` — which is not
|
||||
* writable. The write threw, the generic catch swallowed it, and the caller reported only
|
||||
* "failed to download or failed signature verification". Nothing was ever written, so there was
|
||||
* no partial file to find and nothing in the message pointed at storage. It fails on EVERY
|
||||
* attempt, forever, on an affected panel — and identically for the pushed-APK path, which had
|
||||
* the same line.
|
||||
*
|
||||
* Internal storage always exists, so fall back to it. It costs nothing when external is present.
|
||||
* NOTE: the intent-based install fallback resolves this file through FileProvider, so
|
||||
* res/xml/file_paths.xml must expose this directory too — see the <files-path> entry there.
|
||||
*/
|
||||
/*
|
||||
* Where to stage a downloaded APK — the FIRST location that actually accepts bytes.
|
||||
*
|
||||
* Internal app storage is tried first and is effectively guaranteed: /data/data/<pkg>/files is
|
||||
* this app's own private directory, always mounted, always writable. If it is not, the app is
|
||||
* not running. External storage is only a convenience (it survives uninstall and is visible for
|
||||
* a manual install), and it is the one that fails — it can be absent, unmounted, present but
|
||||
* unwritable, or report canWrite() = true and then refuse the write anyway.
|
||||
*
|
||||
* ⚠️ Each candidate is PROVEN with a real write, not asked. The previous version asked
|
||||
* canWrite(), believed the answer, and then died at outputStream() — before a single byte — so
|
||||
* the update failed instantly and reported it as a download problem. Every fallback in the world
|
||||
* is useless if the first choice is trusted rather than tested.
|
||||
*
|
||||
* Returns the directory, or null with every reason it could not find one, so the operator gets
|
||||
* the full picture instead of the first excuse.
|
||||
*/
|
||||
private fun apkStagingDir(needBytes: Long): Pair<File?, String> {
|
||||
val candidates = LinkedHashMap<String, File>()
|
||||
candidates["internal"] = File(context.filesDir, "Download")
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.let { candidates["external"] = it }
|
||||
candidates["cache"] = File(context.cacheDir, "Download")
|
||||
candidates["files"] = context.filesDir // last resort: no subdirectory to create
|
||||
|
||||
val reasons = StringBuilder()
|
||||
for ((name, dir) in candidates) {
|
||||
val problem = apkDirProblem(dir, needBytes)
|
||||
if (problem == null) {
|
||||
if (name != "internal") Log.w(TAG, "Staging APK in $name (${dir.absolutePath})")
|
||||
return dir to name
|
||||
}
|
||||
if (reasons.isNotEmpty()) reasons.append("; ")
|
||||
reasons.append("$name ${problem}")
|
||||
}
|
||||
return null to reasons.toString()
|
||||
}
|
||||
|
||||
private fun apkDirProblem(dir: File, needBytes: Long): String? {
|
||||
if (!dir.exists() && !dir.mkdirs()) return "cannot create ${dir.absolutePath}"
|
||||
if (!dir.isDirectory) return "${dir.absolutePath} is not a directory"
|
||||
if (!dir.canWrite()) return "no write permission on ${dir.absolutePath}"
|
||||
val free = try { dir.usableSpace } catch (_: Throwable) { -1L }
|
||||
// Headroom, not an exact fit: the installer stages its own copy of the APK as well, so a
|
||||
// volume with barely the download's worth free still fails at install time.
|
||||
if (needBytes > 0 && free in 0 until (needBytes * 2)) {
|
||||
return "only ${free / 1024 / 1024}MB free on ${dir.absolutePath}, need ~${needBytes * 2 / 1024 / 1024}MB"
|
||||
}
|
||||
// Prove it rather than infer it: canWrite() can be true on a volume that refuses the write.
|
||||
return try {
|
||||
val probe = File(dir, ".st-write-probe")
|
||||
probe.writeBytes(byteArrayOf(1))
|
||||
probe.delete()
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
"write test failed in ${dir.absolutePath}: ${e.javaClass.simpleName} ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAndInstall(url: String, version: String): Boolean {
|
||||
try {
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
"ScreenTinker-$version.apk")
|
||||
// Find somewhere that will actually take the file, before asking the network for it.
|
||||
val (dir, whereOrWhy) = apkStagingDir(9L * 1024 * 1024)
|
||||
if (dir == null) {
|
||||
lastFailure = "nowhere to stage the update — $whereOrWhy"
|
||||
Log.e(TAG, "APK staging unavailable: $whereOrWhy")
|
||||
return false
|
||||
}
|
||||
val apkFile = File(dir, "ScreenTinker-$version.apk")
|
||||
|
||||
// #139: reuse a previously-downloaded, verified APK for this version instead of
|
||||
// re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a
|
||||
// manual install when silent install isn't possible.
|
||||
if (apkFile.exists() && verifyApkSignature(apkFile)) {
|
||||
if (apkFile.exists() && cachedApkIs(apkFile, version) && verifyApkSignature(apkFile)) {
|
||||
Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
|
||||
handler.post { installApk(apkFile) }
|
||||
return true
|
||||
|
|
@ -332,6 +428,7 @@ class UpdateChecker(private val context: Context) {
|
|||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
lastFailure = "server returned HTTP ${response.code} for the APK"
|
||||
Log.e(TAG, "Download failed: ${response.code}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -351,7 +448,21 @@ class UpdateChecker(private val context: Context) {
|
|||
// Verify the downloaded APK is our package AND signed by the same key as
|
||||
// the currently-installed app before installing. An attacker can't forge
|
||||
// our signature, so this holds even over an untrusted transport.
|
||||
// The server advertises a version and separately serves a file; the two can drift. A
|
||||
// stale APK behind a current version number installs as a NO-OP, so the version never
|
||||
// changes, the update is attempted again, and the panel loops until its attempts are
|
||||
// spent — reporting a download failure, which it is not. Say what actually happened.
|
||||
if (!cachedApkIs(apkFile, version)) {
|
||||
val got = apkVersionName(apkFile) ?: "unreadable"
|
||||
lastFailure = "server served $got but advertised $version — the update on the server is stale"
|
||||
Log.e(TAG, "Version mismatch: advertised $version, downloaded $got")
|
||||
apkFile.delete()
|
||||
return false
|
||||
}
|
||||
if (!verifyApkSignature(apkFile)) {
|
||||
// lastFailure was set precisely inside verifyApkSignature; keep it, and add the
|
||||
// size so a truncated download is distinguishable from a genuine cert mismatch.
|
||||
lastFailure = "${lastFailure ?: "signature verification failed"} (downloaded ${apkFile.length()} bytes)"
|
||||
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
|
||||
apkFile.delete()
|
||||
return false
|
||||
|
|
@ -364,6 +475,7 @@ class UpdateChecker(private val context: Context) {
|
|||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
lastFailure = "download/install threw ${e.javaClass.simpleName}: ${e.message}"
|
||||
Log.e(TAG, "Download/install error: ${e.message}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -378,7 +490,9 @@ class UpdateChecker(private val context: Context) {
|
|||
try {
|
||||
val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" }
|
||||
val fileName = "pushed-" + (if (base.endsWith(".apk")) base else "$base.apk")
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
|
||||
val (dir, whyNot) = apkStagingDir(9L * 1024 * 1024)
|
||||
if (dir == null) { Log.e(TAG, "installFromUrl: nowhere to stage — $whyNot"); return@Thread }
|
||||
val apkFile = File(dir, fileName)
|
||||
if (apkFile.exists()) apkFile.delete()
|
||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
if (!response.isSuccessful) { Log.e(TAG, "installFromUrl: download failed ${response.code}"); return@Thread }
|
||||
|
|
@ -482,6 +596,52 @@ class UpdateChecker(private val context: Context) {
|
|||
|
||||
// True only if the downloaded APK is this same package and shares a signing
|
||||
// certificate with the installed app. Fail-closed on any error.
|
||||
/* The versionName inside an APK file, or null if it cannot be read. */
|
||||
private fun apkVersionName(apkFile: File): String? = try {
|
||||
context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)?.versionName
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Could not read version from ${apkFile.name}: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
/*
|
||||
* Is this file actually the version we mean to install?
|
||||
*
|
||||
* The cache is keyed by FILENAME, and the filename is built from the version the server
|
||||
* advertised — so a file called ScreenTinker-1.9.34.apk containing 1.9.33 passes a signature
|
||||
* check (same key), gets reused on every attempt, and installs as a no-op forever. Fixing the
|
||||
* server does not clear it; only deleting the file does. Checking the version inside makes that
|
||||
* self-healing instead of needing a hand on the device.
|
||||
*/
|
||||
private fun cachedApkIs(apkFile: File, version: String): Boolean {
|
||||
val got = apkVersionName(apkFile) ?: return false
|
||||
if (got == version) return true
|
||||
Log.w(TAG, "Cached ${apkFile.name} contains $got, expected $version — discarding")
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Delete every staged APK. The escape hatch for a panel holding a bad download: it forces the
|
||||
* next check to fetch again rather than reuse. Safe at any time — these are only ever caches,
|
||||
* re-fetched on demand.
|
||||
*/
|
||||
fun clearUpdateCache(): Int {
|
||||
var n = 0
|
||||
for (dir in listOfNotNull(
|
||||
File(context.filesDir, "Download"),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
File(context.cacheDir, "Download"),
|
||||
)) {
|
||||
val files = try { dir.listFiles() } catch (_: Throwable) { null } ?: continue
|
||||
for (f in files) {
|
||||
if (!f.name.endsWith(".apk")) continue
|
||||
if (f.delete()) n++
|
||||
}
|
||||
}
|
||||
report("info", "Update cache cleared ($n file(s)) — the next check will download afresh")
|
||||
return n
|
||||
}
|
||||
|
||||
private fun verifyApkSignature(apkFile: File): Boolean {
|
||||
return try {
|
||||
val pm = context.packageManager
|
||||
|
|
@ -498,10 +658,12 @@ class UpdateChecker(private val context: Context) {
|
|||
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags)
|
||||
if (downloaded == null) {
|
||||
lastFailure = "the downloaded file could not be parsed as an APK (truncated or not an APK)"
|
||||
Log.e(TAG, "Could not parse downloaded APK")
|
||||
return false
|
||||
}
|
||||
if (downloaded.packageName != context.packageName) {
|
||||
lastFailure = "APK is package ${downloaded.packageName}, expected ${context.packageName}"
|
||||
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -511,18 +673,37 @@ class UpdateChecker(private val context: Context) {
|
|||
val installedFlags = if (installedUsesSigningInfo)
|
||||
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
val installed = pm.getPackageInfo(context.packageName, installedFlags)
|
||||
val downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
|
||||
var downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
|
||||
// #139 follow-up: on API 28/29 the archive read goes through the legacy GET_SIGNATURES
|
||||
// path, and if PackageManager hands back nothing we previously refused a perfectly good
|
||||
// APK with no way to tell that apart from a real mismatch. Read the v1 signature
|
||||
// ourselves before giving up — JarFile is random-access, which is how the JAR signature
|
||||
// is meant to be read, and it verifies the same bytes PackageManager would have.
|
||||
// This does NOT weaken the check: the cert extracted here is still compared against the
|
||||
// installed app's below, and an unsigned or differently-signed APK still fails.
|
||||
if (downloadedSigs.isEmpty()) {
|
||||
val viaJar = archiveCertsViaJar(apkFile)
|
||||
if (viaJar.isNotEmpty()) {
|
||||
Log.w(TAG, "Archive certs unreadable via PackageManager on API ${Build.VERSION.SDK_INT}; used JarFile (${viaJar.size})")
|
||||
downloadedSigs = viaJar
|
||||
}
|
||||
}
|
||||
val installedSigs = signingCertHashes(installed, installedUsesSigningInfo)
|
||||
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
|
||||
lastFailure = "could not read signing certificates (archive=${downloadedSigs.size}, installed=${installedSigs.size}) on API ${Build.VERSION.SDK_INT}"
|
||||
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
|
||||
return false
|
||||
}
|
||||
// Require a non-empty overlap of signer certs (handles multi-signer / cert-rotation
|
||||
// the same way the API>=30 path does: compare the full current signer sets).
|
||||
val match = downloadedSigs.any { it in installedSigs }
|
||||
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
|
||||
if (!match) {
|
||||
lastFailure = "APK is signed by a different key than the installed app"
|
||||
Log.e(TAG, "APK signing certificate does not match installed app")
|
||||
}
|
||||
match
|
||||
} catch (e: Exception) {
|
||||
lastFailure = "signature check threw ${e.javaClass.simpleName}: ${e.message}"
|
||||
Log.e(TAG, "Signature verification error: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
|
|
@ -533,6 +714,31 @@ class UpdateChecker(private val context: Context) {
|
|||
// multi-signer + rotation aware), GET_SIGNATURES -> legacy .signatures (the only field
|
||||
// populated for ARCHIVE reads on API 28/29). Both yield the same cert for a normally-signed
|
||||
// APK; the caller compares as sets so an overlapping signer still verifies.
|
||||
/*
|
||||
* Read the APK's v1 (JAR) signer certificates directly, as a fallback for the API 28/29 archive
|
||||
* read. Opening JarFile with verify=true and reading an entry to completion is what populates
|
||||
* JarEntry.certificates — the certificate is only known once the bytes it covers have been
|
||||
* checked, so the read is the verification, not a step before it.
|
||||
*
|
||||
* Returns an empty set on any problem, which leaves the caller refusing the install: this is a
|
||||
* fallback for "PackageManager told us nothing", never a way to skip the comparison.
|
||||
*/
|
||||
private fun archiveCertsViaJar(apkFile: File): Set<String> {
|
||||
return try {
|
||||
java.util.jar.JarFile(apkFile, true).use { jar ->
|
||||
val entry = jar.getJarEntry("AndroidManifest.xml") ?: return emptySet()
|
||||
jar.getInputStream(entry).use { input ->
|
||||
val buf = ByteArray(8192)
|
||||
while (input.read(buf) != -1) { /* must read fully before certificates populate */ }
|
||||
}
|
||||
entry.certificates?.mapNotNull { sha256(it.encoded) }?.toSet() ?: emptySet()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "JarFile cert read failed: ${e.message}")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun signingCertHashes(info: PackageInfo, useSigningInfo: Boolean): Set<String> {
|
||||
val sigs: Array<Signature>? = if (useSigningInfo) {
|
||||
info.signingInfo?.apkContentsSigners
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ class DeviceInfo(private val context: Context) {
|
|||
// ISP's address as their screen's IP. Needs no permission — read straight off the
|
||||
// interfaces, so it works on Ethernet panels too, not just Wi-Fi.
|
||||
put("local_ip", getLocalIp() ?: JSONObject.NULL)
|
||||
// Both stacks, not one: getLocalIp() filters to Inet4Address, so a v6-only panel
|
||||
// reported NOTHING and the dashboard showed a dash for a screen that had a perfectly
|
||||
// good address. A dual-stack panel now shows both.
|
||||
put("local_ip6", getLocalIp6() ?: JSONObject.NULL)
|
||||
put("wifi_rssi", getWifiRSSI())
|
||||
put("uptime_seconds", getUptimeSeconds())
|
||||
// #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator)
|
||||
|
|
@ -221,6 +225,41 @@ class DeviceInfo(private val context: Context) {
|
|||
found
|
||||
} catch (e: Throwable) { null }
|
||||
|
||||
/**
|
||||
* The panel's own IPv6 address, reported alongside the v4 one rather than instead of it —
|
||||
* a dual-stack screen has both and an operator may need either.
|
||||
*
|
||||
* Deliberately NOT link-local (fe80::/10). Every interface has one, they are the addresses
|
||||
* most likely to be enumerated first, and none of them can be dialled without also knowing
|
||||
* the zone index — so putting one in the dashboard would fill the field with a string that
|
||||
* cannot be pasted anywhere useful and hide the address that can. A global or unique-local
|
||||
* address is the one someone reaching the panel on site actually needs.
|
||||
*
|
||||
* The scope check also drops multicast and the unspecified address; what survives is a
|
||||
* routable unicast address. `hostAddress` can carry a %iface suffix on some builds, so it is
|
||||
* trimmed — the field is for humans and for pasting into a browser.
|
||||
*/
|
||||
private fun getLocalIp6(): String? = try {
|
||||
var found: String? = null
|
||||
val ifaces = java.net.NetworkInterface.getNetworkInterfaces()
|
||||
while (ifaces != null && ifaces.hasMoreElements() && found == null) {
|
||||
val iface = ifaces.nextElement()
|
||||
if (!iface.isUp || iface.isLoopback) continue
|
||||
val addrs = iface.inetAddresses
|
||||
while (addrs.hasMoreElements()) {
|
||||
val addr = addrs.nextElement()
|
||||
if (addr is java.net.Inet6Address &&
|
||||
!addr.isLoopbackAddress && !addr.isLinkLocalAddress &&
|
||||
!addr.isAnyLocalAddress && !addr.isMulticastAddress
|
||||
) {
|
||||
found = addr.hostAddress?.substringBefore('%')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
} catch (e: Throwable) { null }
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getWifiRSSI(): Int {
|
||||
return try {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,27 @@ object PlayerCapabilities {
|
|||
// Native view rotation: the ExoPlayer surface sits inside the rotated view, so video
|
||||
// turns with the graphics. No hardware-plane problem here.
|
||||
"display.rotation",
|
||||
// Per-window overlay dim (WindowManager.LayoutParams.screenBrightness) — Tier 0, no
|
||||
// permission, works at any privilege level. Distinct from "system.brightness" below,
|
||||
// which writes the system-wide setting and DOES need WRITE_SETTINGS or owner.
|
||||
// Declared here because the android BASELINE already grants it: without this line an
|
||||
// updated panel replaces the baseline with a declared set that lacks it, and LOSES
|
||||
// the dim slider it had before it updated.
|
||||
"display.brightness",
|
||||
// Capture, at ANY privilege level. captureScreen() is a three-rung fallback —
|
||||
// MediaProjection (system-wide, needs consent), then the accessibility screenshot
|
||||
// API, then ScreenshotCapture.captureView, which is a plain view draw with no
|
||||
// permission check of any kind. The last rung is narrower than the others (the
|
||||
// player's own window, foreground only) but on a kiosk panel that window IS the
|
||||
// content, so the operator gets a picture rather than a refusal.
|
||||
//
|
||||
// Declared unconditionally for the same reason display.brightness is: the android
|
||||
// BASELINE grants both, and a declared set replaces the baseline rather than
|
||||
// merging with it. Gating on accessibility meant a panel LOST live view and
|
||||
// screenshots by updating, while the fallback that still served them kept working.
|
||||
// It also made granting MediaProjection invisible — consent was given, capture
|
||||
// genuinely started, and the server went on refusing because nothing re-declared.
|
||||
"remote.screenshot", "remote.stream",
|
||||
// Input is plain view dispatch and works regardless of privilege.
|
||||
"remote.input",
|
||||
// The player restarts itself; the OTA checker updates the APK.
|
||||
|
|
@ -67,10 +88,6 @@ object PlayerCapabilities {
|
|||
|
||||
// ---- conditional on runtime state -------------------------------------------------------
|
||||
|
||||
// Full-screen capture needs the accessibility service; without it capture falls back to
|
||||
// the app's own view. Declared only for the real thing, per the capability contract.
|
||||
if (accessibility) caps += listOf("remote.screenshot", "remote.stream")
|
||||
|
||||
// Display power is asymmetric and only honest when BOTH halves exist. screen_off needs
|
||||
// owner, device-admin FORCE_LOCK, or accessibility; screen_on now works anywhere via a
|
||||
// wake lock (WAKE_LOCK is a normal permission). So the binding constraint is the OFF
|
||||
|
|
@ -86,6 +103,13 @@ object PlayerCapabilities {
|
|||
// confirmation — unusable on a panel with no input, so not claimed.
|
||||
if (isOwner) caps += "system.kiosk"
|
||||
|
||||
// The privilege itself, declared as a capability. Every #161 Tier-2 command
|
||||
// (lock_now / power_menu / status_bar / block_uninstall / unblock_uninstall) gates on
|
||||
// this name. No player declared it, so the server accepts "system.kiosk" as a stand-in —
|
||||
// exact, because kiosk is itself owner-only, but a stand-in nonetheless. Declaring the
|
||||
// canonical name makes those refusals say what they mean and lets the stand-in retire.
|
||||
if (isOwner) caps += "system.device_owner"
|
||||
|
||||
// Owner-only clock control.
|
||||
if (isOwner) caps += "system.time"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,4 +2,11 @@
|
|||
<paths>
|
||||
<external-files-path name="downloads" path="Download/" />
|
||||
<external-files-path name="apk" path="." />
|
||||
<!-- UpdateChecker.apkDir() stages APKs in internal storage when external storage is not
|
||||
available (getExternalFilesDir returns null). The silent PackageInstaller path streams the
|
||||
file itself and needs nothing here, but the intent-based install FALLBACK resolves it
|
||||
through FileProvider — without this entry that fallback throws
|
||||
IllegalArgumentException ("Failed to find configured root"), turning an already-degraded
|
||||
panel into one that cannot install at all. -->
|
||||
<files-path name="internal_downloads" path="Download/" />
|
||||
</paths>
|
||||
|
|
|
|||
|
|
@ -57,4 +57,49 @@ class PlaylistSelectionTest {
|
|||
@Test fun `a single downloaded item loops instead of blanking`() {
|
||||
assertEquals(0, PlaylistSelection.nextPlayableIndex(1, 0, readyPredicate(0)))
|
||||
}
|
||||
|
||||
// ===== the cold-start re-check: item 1 of the playlist must not be skipped =====
|
||||
//
|
||||
// Observed on the emulator: a freshly paired panel gets its playlist BEFORE the media has
|
||||
// downloaded, so start() finds nothing playable and the 3-second content re-check is what
|
||||
// actually begins playback. updatePlaylist() has already seeded currentIndex = 0, and the
|
||||
// re-check used to advance PAST it — so a 4-item playlist played 1,2,3,0 on its first pass and
|
||||
// a 2-item playlist looked like "only one of the two ever plays".
|
||||
|
||||
@Test fun `a cold-start re-check begins at the seeded index instead of skipping past it`() {
|
||||
// currentIndex seeded to 0, nothing on screen yet, everything now downloaded.
|
||||
assertEquals("item 0 has never played — it must not be skipped", 0,
|
||||
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate(0, 1, 2, 3)))
|
||||
}
|
||||
|
||||
@Test fun `a re-check with content already on screen still advances past the current item`() {
|
||||
// The other half of the rule: a real position has had its turn, so we must move on.
|
||||
assertEquals(1,
|
||||
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = true, isPlayable = readyPredicate(0, 1, 2, 3)))
|
||||
}
|
||||
|
||||
@Test fun `a cold-start re-check still skips an item whose content is not downloaded`() {
|
||||
// Item 0 is still downloading; the panel starts on the first item it can actually show.
|
||||
assertEquals(2,
|
||||
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate(2, 3)))
|
||||
}
|
||||
|
||||
@Test fun `a cold-start re-check with no position yet starts at the top, not the last item`() {
|
||||
// currentIndex is -1 before updatePlaylist seeds it; wrapping onto the last item here would
|
||||
// start a fresh panel at the END of its playlist.
|
||||
assertEquals(0,
|
||||
PlaylistSelection.recheckIndex(3, from = -1, hasContentOnScreen = false, isPlayable = readyPredicate(0, 1, 2)))
|
||||
}
|
||||
|
||||
@Test fun `a cold-start re-check returns -1 while nothing is downloaded`() {
|
||||
assertEquals(-1,
|
||||
PlaylistSelection.recheckIndex(3, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate()))
|
||||
}
|
||||
|
||||
@Test fun `playableFromIndex is inclusive of its start and wraps`() {
|
||||
assertEquals(1, PlaylistSelection.playableFromIndex(4, 1, readyPredicate(1, 3)))
|
||||
assertEquals(3, PlaylistSelection.playableFromIndex(4, 2, readyPredicate(1, 3)))
|
||||
assertEquals(1, PlaylistSelection.playableFromIndex(4, 3, readyPredicate(1))) // wraps
|
||||
assertEquals(-1, PlaylistSelection.playableFromIndex(0, 0, readyPredicate(0)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
android/licenses.json
Normal file
45
android/licenses.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"_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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -30,7 +30,25 @@
|
|||
' So ask the filesystem instead of assuming: whichever volume holds this script is the volume
|
||||
' that holds everything else beside it.
|
||||
Function StorageRoot() As String
|
||||
' Which volume are we actually running from? Everything else is derived from this — the offline
|
||||
' page, the widget's storage directory, the self-update paths — so getting it wrong points the
|
||||
' whole player at a volume that may not physically exist.
|
||||
'
|
||||
' Probed in the order the OS itself searches for an autorun script (roStorageHotplug.GetStorages()
|
||||
' documents ["USB1:/", "SD:/", "SD2:/", "SSD:/", "FLASH:/"]), so the answer matches the volume the
|
||||
' player actually booted from. FLASH is last because it is the fallback of last resort: the unit
|
||||
' this was developed on has a dead card slot and boots from internal flash, and an earlier version
|
||||
' of this function knew only FLASH and SD — so fitting real storage to that player and moving the
|
||||
' files onto it would have silently resolved every path to "SD:", a slot with nothing in it.
|
||||
'
|
||||
' ReadFile rather than a MatchFiles existence check: MatchFiles takes a DIRECTORY plus a pattern
|
||||
' and returns nothing when the pattern contains a separator, which is why the helper further down
|
||||
' this file never finds anything.
|
||||
ba = CreateObject("roByteArray")
|
||||
if ba.ReadFile("USB1:/autorun.brs") then return "USB1:"
|
||||
if ba.ReadFile("SSD:/autorun.brs") then return "SSD:"
|
||||
if ba.ReadFile("SD:/autorun.brs") then return "SD:"
|
||||
if ba.ReadFile("SD2:/autorun.brs") then return "SD2:"
|
||||
if ba.ReadFile("FLASH:/autorun.brs") then return "FLASH:"
|
||||
return "SD:"
|
||||
End Function
|
||||
|
|
@ -82,6 +100,41 @@ Function LoadConfig() As Object
|
|||
return cfg
|
||||
End Function
|
||||
|
||||
Function SnapshotDir() As String
|
||||
' DWS writes to ITS primary storage, which is not necessarily the volume the presentation
|
||||
' booted from — so probe rather than assume, in the same order StorageRoot() does.
|
||||
for each v in ["USB1:", "SSD:", "SD:", "SD2:", "FLASH:"]
|
||||
d$ = v + "/remote_snapshots"
|
||||
files = MatchFiles(d$, "*.jpg")
|
||||
if files <> invalid and files.Count() > 0 then return d$
|
||||
end for
|
||||
return ""
|
||||
End Function
|
||||
|
||||
Function NewestFile(dir As String, pattern As String) As String
|
||||
' DWS names captures img-YYYY-MM-DD-HH-MM-SS.jpg, so the lexicographic maximum IS the newest.
|
||||
best$ = ""
|
||||
files = MatchFiles(dir, pattern)
|
||||
if files = invalid then return ""
|
||||
for each f in files
|
||||
if f > best$ then best$ = f
|
||||
end for
|
||||
return best$
|
||||
End Function
|
||||
|
||||
Function DwsPort() As String
|
||||
' Which port the local Diagnostic Web Server answers on. Read from the same registry the
|
||||
' DWS itself is configured from, so a player moved off port 80 still gets framebuffer
|
||||
' captures instead of silently degrading to the canvas path.
|
||||
port$ = "80"
|
||||
reg = CreateObject("roRegistrySection", "networking")
|
||||
if reg <> invalid and reg.Exists("http_server") then
|
||||
v$ = reg.Read("http_server").Trim()
|
||||
if v$ <> "" then port$ = v$
|
||||
end if
|
||||
return port$
|
||||
End Function
|
||||
|
||||
Sub SaveRegistry(key As String, value As String)
|
||||
reg = CreateObject("roRegistrySection", "screentinker")
|
||||
reg.Write(key, value)
|
||||
|
|
@ -108,15 +161,31 @@ End Function
|
|||
Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object) As Object
|
||||
config = {
|
||||
url: url
|
||||
nodejs_enabled: true ' Node runtime inside the widget
|
||||
brightsign_js_objects_enabled: true ' REQUIRED for require("@brightsign/*") — without
|
||||
' this the bridge silently degrades to no-ops and
|
||||
' the player loses identity AND restart delegation
|
||||
' THIS is what gates require("@brightsign/*"). Without it the bridge silently degrades to
|
||||
' no-ops and the player loses identity AND restart delegation. ("BrightSign modules are
|
||||
' actually part of the firmware, but in terms of usage they are identical to other Node.js
|
||||
' modules" — so no Node runtime means no modules.)
|
||||
nodejs_enabled: true
|
||||
' NOT what gates require(). This flag enables the LEGACY GLOBAL objects — BSDeviceInfo,
|
||||
' BSMessagePort and friends — which this bridge does not use; BrightSign's own cookbook
|
||||
' examples call require("@brightsign/bt") with nodejs_enabled alone. Kept set because
|
||||
' several of their samples set both and it costs nothing, but the comment that used to sit
|
||||
' here credited it with holding the whole bridge up, which would send the next person
|
||||
' debugging a dead bridge to exactly the wrong line.
|
||||
brightsign_js_objects_enabled: true
|
||||
javascript_enabled: true
|
||||
security_params: { websecurity: true }
|
||||
hwz_default: "on" ' hardware z-order — video on its own plane
|
||||
storage_path: "/cache" ' DIRECTORY NAME for the local storage cache
|
||||
storage_quota: "1073741824" ' 1GB, as a STRING — service-worker offline cache
|
||||
' An ABSOLUTE path on the volume we booted from. "/cache" carries no BrightSign drive
|
||||
' specifier, so it resolves outside the writable volumes and the widget's local storage —
|
||||
' the backing store a service worker, the Cache API and IndexedDB all need — has nowhere to
|
||||
' persist to. The XT245 on alpha exposes navigator.serviceWorker and then refuses to
|
||||
' register one, which is exactly what a widget with no usable storage would do.
|
||||
storage_path: StorageRoot() + "/cache" ' local storage, on the volume we booted from
|
||||
' 1GB, as a DOUBLE. The docs are explicit: "A BrightScript integer is only guaranteed to be
|
||||
' able to represent a count of bytes up to 2GB so avoid using integers... Use float or double
|
||||
' instead... (string can also be used but is not recommended)". This was a string.
|
||||
storage_quota: 1073741824.0
|
||||
port: port
|
||||
mouse_enabled: false
|
||||
}
|
||||
|
|
@ -170,7 +239,13 @@ Sub TakeSnapshot(widget As Object, req As Object)
|
|||
if req <> invalid and req.width <> invalid then w% = req.width
|
||||
if req <> invalid and req.height <> invalid then h% = req.height
|
||||
|
||||
body$ = "{""width"":" + Stri(w%).Trim() + ",""height"":" + Stri(h%).Trim() + "}"
|
||||
' BrightScript has NO escape sequences in string literals: "" does not mean an escaped quote,
|
||||
' it ends one string and begins another, so `"{""width"":"` is three literals with no operator
|
||||
' between them — a compile error that stops the WHOLE SCRIPT loading, not just this function.
|
||||
' A quote has to come from Chr(34). This line is why the player booted to nothing:
|
||||
' ScriptLoadError: Syntax Error. (compile error &h02) in SSD:/autorun.brs(196)
|
||||
q$ = Chr(34)
|
||||
body$ = "{" + q$ + "width" + q$ + ":" + Stri(w%).Trim() + "," + q$ + "height" + q$ + ":" + Stri(h%).Trim() + "}"
|
||||
|
||||
ut = CreateObject("roUrlTransfer")
|
||||
if ut = invalid then
|
||||
|
|
@ -178,36 +253,67 @@ Sub TakeSnapshot(widget As Object, req As Object)
|
|||
return
|
||||
end if
|
||||
|
||||
ut.SetUrl("http://localhost/api/v1/snapshot/")
|
||||
' The DWS port is NOT always 80. It is configurable and BSN/Supervisor-provisioned players
|
||||
' are commonly moved off it — the unit this was found on serves DWS on 8080 with nothing
|
||||
' listening on 80 at all. Hardcoding 80 meant every host snapshot failed to connect, fell
|
||||
' through to the in-page canvas, and the canvas cannot read the hardware video plane, so the
|
||||
' operator got a card reading "Video is playing on the hardware plane and cannot be captured"
|
||||
' while the very same capture worked perfectly from the DWS Snapshots tab.
|
||||
'
|
||||
' The port lives in the networking registry section as http_server; absent means the default.
|
||||
' 127.0.0.1, NOT "localhost". A name has to be resolved, and on this platform that resolution
|
||||
' is not ours to rely on: if it answers ::1 first the connection goes to an address the DWS is
|
||||
' not listening on and the transfer sits there until something times out — which is exactly the
|
||||
' shape of the failure this chased (the page gave up at 15s having heard nothing at all, not
|
||||
' even this Sub's own timeout). A literal address cannot be resolved wrongly.
|
||||
ut.SetUrl("http://127.0.0.1:" + DwsPort() + "/api/v1/snapshot/")
|
||||
ut.SetUserAndPassword("admin", serial$)
|
||||
ut.AddHeader("Content-Type", "application/json")
|
||||
|
||||
resp$ = ut.PostFromStringWithRetry(body$, 1)
|
||||
if resp$ = invalid or resp$ = "" then
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no response from the local DWS" })
|
||||
' SYNCHRONOUS, deliberately — and this is the whole fix.
|
||||
'
|
||||
' PostFromStringWithRetry does not exist (calling it raised "Member function not found" from
|
||||
' inside the event loop, i.e. a snapshot request took the whole player down). The obvious
|
||||
' alternative, AsyncPostFromString + Wait on a private port, is the documented way to read a
|
||||
' POST body — and on this hardware its roUrlEvent NEVER ARRIVES. The Sub simply sat in Wait
|
||||
' while st-bridge.js gave up at 15s, so the page reported "host did not answer in time" and
|
||||
' fell back to the canvas, which cannot read the hardware video plane. Every other transfer in
|
||||
' this file is synchronous (GetToString for the package check, GetToFile for the download) and
|
||||
' every one of them works, including the self-update that replaced this very script.
|
||||
'
|
||||
' PostFromString returns only the response CODE and discards the body — which would normally
|
||||
' lose the thumbnail. It does not matter here: DWS WRITES THE CAPTURE TO PRIMARY STORAGE before
|
||||
' it answers (the body carries a `filename` pointing at it), so the file is on disk by the time
|
||||
' the call returns and can simply be read back.
|
||||
code% = ut.PostFromString(body$)
|
||||
if code% <> 200 then
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS refused the capture (HTTP " + Stri(code%).Trim() + " on port " + DwsPort() + ")" })
|
||||
return
|
||||
end if
|
||||
|
||||
json = ParseJson(resp$)
|
||||
if json = invalid or json.data = invalid then
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "unparseable DWS response" })
|
||||
dir$ = SnapshotDir()
|
||||
if dir$ = "" then
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS wrote no capture to any volume" })
|
||||
return
|
||||
end if
|
||||
|
||||
if json.data.error <> invalid then
|
||||
' e.g. "No primary storage found." — pass the player's own words through; inventing a
|
||||
' friendlier message here would hide the one fact that explains the failure.
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: json.data.error.message })
|
||||
newest$ = NewestFile(dir$, "*.jpg")
|
||||
if newest$ = "" then
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no capture found in " + dir$ })
|
||||
return
|
||||
end if
|
||||
|
||||
r = json.data.result
|
||||
if r = invalid or r.remotesnapshotthumbnail = invalid then
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS returned no thumbnail" })
|
||||
ba2 = CreateObject("roByteArray")
|
||||
if not ba2.ReadFile(dir$ + "/" + newest$) then
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "could not read " + newest$ })
|
||||
return
|
||||
end if
|
||||
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: true, image: r.remotesnapshotthumbnail })
|
||||
' Read, hand over, then remove: DWS appends a new file per capture and nothing else prunes
|
||||
' them, so a 1fps remote-control stream would otherwise fill the volume.
|
||||
img$ = "data:image/jpeg;base64," + ba2.ToBase64String()
|
||||
DeleteFile(dir$ + "/" + newest$)
|
||||
widget.PostJSMessage({ type: "snapshot-result", ok: true, image: img$ })
|
||||
End Sub
|
||||
|
||||
' Rotate the OUTPUT, not the DOM.
|
||||
|
|
@ -235,18 +341,55 @@ Sub SetOrientation(widget As Object, o As String)
|
|||
return
|
||||
end if
|
||||
|
||||
mode$ = vm.GetMode()
|
||||
if mode$ = invalid or mode$ = "" then mode$ = "1920x1080x60p"
|
||||
|
||||
ok = vm.SetMode(mode$, transform$)
|
||||
if ok = invalid then ok = false
|
||||
|
||||
if ok then
|
||||
print "[st] orientation "; o; " -> transform "; transform$
|
||||
else
|
||||
print "[st] orientation "; o; ": SetMode refused transform "; transform$
|
||||
' SetMode() takes ONE argument — a mode string. Passing a transform as a second argument was a
|
||||
' "wrong number of function parameters" abort, so this Sub never reached its own reply and the
|
||||
' page never learned to fall back. Rotation lives on SetScreenModes(), whose per-screen config
|
||||
' carries a `transform` of normal|90|180|270 and rotates EVERYTHING including the video plane.
|
||||
' Implemented in BOS 9.0.15+; an older player simply has no method here and is told so.
|
||||
' FindMemberFunction is the documented way to ask whether a method exists on this OS version —
|
||||
' safer than naming a member directly, which would attempt the call. It is itself feature-gated
|
||||
' (see HasFindMember), and a player that cannot ask cannot be told the answer is yes: rotation
|
||||
' is refused rather than risked, and the page keeps its CSS fallback.
|
||||
if not HasFindMember() then
|
||||
print "[st] orientation: cannot probe this OS for SetScreenModes — keeping the CSS fallback"
|
||||
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "cannot probe this OS version" })
|
||||
return
|
||||
end if
|
||||
widget.PostJSMessage({ type: "orientation-result", ok: ok, transform: transform$ })
|
||||
if FindMemberFunction(vm, "GetScreenModes") = invalid or FindMemberFunction(vm, "SetScreenModes") = invalid then
|
||||
print "[st] orientation: this OS has no SetScreenModes — the page keeps its CSS fallback"
|
||||
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "SetScreenModes unavailable" })
|
||||
return
|
||||
end if
|
||||
|
||||
configs = vm.GetScreenModes()
|
||||
if configs = invalid or configs.Count() = 0 then
|
||||
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "no screen configuration" })
|
||||
return
|
||||
end if
|
||||
|
||||
' ⚠️ SetScreenModes REBOOTS the player when it changes the screen configuration. A playlist push
|
||||
' repeats the current orientation on every update, so applying it unconditionally would reboot
|
||||
' the display every time the server spoke to it. Only a real change is worth a reboot.
|
||||
changed = false
|
||||
for each c in configs
|
||||
if c.transform <> transform$ then
|
||||
c.transform = transform$
|
||||
changed = true
|
||||
end if
|
||||
end for
|
||||
|
||||
if not changed then
|
||||
print "[st] orientation already "; transform$; " — nothing to do"
|
||||
widget.PostJSMessage({ type: "orientation-result", ok: true, transform: transform$ })
|
||||
return
|
||||
end if
|
||||
|
||||
' Tell the page BEFORE the call: the reboot may take the player out mid-sentence, and a display
|
||||
' that rotates without ever confirming looks like the command was ignored.
|
||||
widget.PostJSMessage({ type: "orientation-result", ok: true, transform: transform$, rebooting: true })
|
||||
print "[st] orientation "; o; " -> transform "; transform$; " (the player will now reboot)"
|
||||
sleep(1000)
|
||||
vm.SetScreenModes(configs)
|
||||
End Sub
|
||||
|
||||
'=== capability probe =======================================================================
|
||||
|
|
@ -263,35 +406,106 @@ End Sub
|
|||
' interface is physically dead, and the DWS still refuses the capture — internal flash is not
|
||||
' "primary storage" as that endpoint means it. Counting it would re-create the exact lie this
|
||||
' probe exists to prevent.
|
||||
' Drop a trailing "/" from a drive specifier. roStorageHotplug.GetStorages() answers with one
|
||||
' ("SSD:/"), the rest of this script speaks the bare form ("SSD:"), and roStorageInfo takes either.
|
||||
Function TrimDrive(raw As String) As String
|
||||
n% = Len(raw)
|
||||
if n% > 0 and Mid(raw, n%, 1) = "/" then return Left(raw, n% - 1)
|
||||
return raw
|
||||
End Function
|
||||
|
||||
' Turn a drive specifier into the one GetStorageStatus() actually accepts.
|
||||
'
|
||||
' GetStorages() -> ["USB1:/", "SD:/", "SD2:/", "SSD:/", "Flash:/"]
|
||||
' GetStorageStatus() understands "USB:", "SD:", "SSD:", "SD2:/", "Flash:" and is documented as
|
||||
' UNRELIABLE for "USBn:". So: drop the trailing slash, and collapse any USBn to a bare "USB:".
|
||||
' roStorageInfo, by contrast, is documented for the NUMBERED form — so the two callers get
|
||||
' different strings and the numbering is only thrown away where it does harm.
|
||||
Function StatusDrive(raw As String) As String
|
||||
d$ = TrimDrive(raw)
|
||||
if LCase(Left(d$, 3)) = "usb" then return "USB:"
|
||||
return d$
|
||||
End Function
|
||||
|
||||
Function StorageProbe() As Object
|
||||
result = { present: false, volume: "", free_mb: 0, total_mb: 0 }
|
||||
|
||||
volumes = ["SSD:", "SD:", "USB1:"]
|
||||
for each v in volumes
|
||||
' "USB:" not "USB1:" — the docs warn that GetStorageStatus() results are UNRELIABLE when called
|
||||
' with a "USBn:" parameter, and list "USB:", "SD:", "SSD:", "SD2:/", "Flash:" as the drive
|
||||
' strings it understands. One roStorageHotplug for the whole loop rather than one per volume.
|
||||
hp = CreateObject("roStorageHotplug")
|
||||
' Ask the platform which volumes exist rather than guessing; the static list is the fallback for
|
||||
' an OS without the enumerator. Same shape BrightSign's own boilerplate uses.
|
||||
'
|
||||
' FLASH: is on this list and is NOT on GetStorageStatus()'s. The documented drive strings for
|
||||
' that method are "SD:", "SSD:" and "USB:" — internal flash is simply not one of the things it
|
||||
' can answer about, so a player booting from flash (which is how the XT245 in this office ran
|
||||
' until an NVMe went in) can never be reported as mounted no matter what is actually there.
|
||||
' Hence the roStorageInfo pass below: the mount check is treated as a hint, not a gate.
|
||||
volumes = ["SSD:", "SD:", "SD2:", "USB:", "FLASH:"]
|
||||
' Feature-gated (see HasFindMember). A player that cannot be probed simply keeps the static list,
|
||||
' which is the answer the enumerator would have given anyway on every model we ship.
|
||||
if hp <> invalid and HasFindMember() then
|
||||
if FindMemberFunction(hp, "GetStorages") <> invalid then
|
||||
found = hp.GetStorages()
|
||||
if found <> invalid and found.Count() > 0 then volumes = found
|
||||
end if
|
||||
end if
|
||||
for each raw in volumes
|
||||
' ⚠️ GetStorages() answers in a DIFFERENT vocabulary to the one GetStorageStatus() accepts:
|
||||
' it returns ["USB1:/", "SD:/", "SD2:/", "SSD:/", "Flash:/"] — trailing slash, and USB
|
||||
' NUMBERED. GetStorageStatus() is documented as UNRELIABLE when called with a "USBn:"
|
||||
' parameter and understands "USB:", "SD:", "SSD:", "SD2:/", "Flash:". So handing the
|
||||
' enumerator's own output straight back to it re-creates exactly the bug the static list was
|
||||
' written to avoid — silently, and only on the OS versions that HAVE the enumerator, which is
|
||||
' why the static fallback looked correct in testing.
|
||||
v = TrimDrive(raw)
|
||||
mounted = false
|
||||
hp = CreateObject("roStorageHotplug")
|
||||
if hp <> invalid then
|
||||
st = hp.GetStorageStatus(v)
|
||||
st = hp.GetStorageStatus(StatusDrive(raw))
|
||||
if st <> invalid and st.mounted then mounted = true
|
||||
end if
|
||||
|
||||
if mounted then
|
||||
result.present = true
|
||||
result.volume = v
|
||||
si = CreateObject("roStorageInfo", v)
|
||||
if si <> invalid then
|
||||
' Real device capacity. The widget's storage quota — all the page can see via
|
||||
' navigator.storage.estimate() — is the cache budget, not the disk.
|
||||
result.free_mb = si.GetFreeInMegabytes()
|
||||
result.total_mb = si.GetSizeInMegabytes()
|
||||
end if
|
||||
return result
|
||||
if FillStorage(result, v) then return result
|
||||
end if
|
||||
end for
|
||||
|
||||
' Nothing claimed to be mounted — which is not the same as nothing being there.
|
||||
'
|
||||
' GetStorageStatus() cannot answer for FLASH:, roStorageHotplug may not exist at all on an older
|
||||
' build, and either way the whole probe hung on one call whose "no" was indistinguishable from
|
||||
' "cannot say". roStorageInfo is the direct question: a volume that reports a non-zero size IS
|
||||
' the disk, whatever the hotplug object thinks. The dashboard was showing 1025 MB for a player
|
||||
' with an NVMe in it — the widget's own cache quota, reported through the fallback in
|
||||
' st-bridge.js — because this function returned present:false and the page had nothing better.
|
||||
'
|
||||
' The mount check still runs FIRST: it is the more meaningful answer where it works, and it
|
||||
' picks the removable volume ahead of internal flash on a player that has both.
|
||||
for each raw in volumes
|
||||
if FillStorage(result, TrimDrive(raw)) then return result
|
||||
end for
|
||||
|
||||
return result
|
||||
End Function
|
||||
|
||||
' Real device capacity for [drive], into [result]. True when the volume answered.
|
||||
'
|
||||
' The widget's storage quota — all the page can see via navigator.storage.estimate() — is the cache
|
||||
' budget, not the disk, which is the entire reason the host is asked at all.
|
||||
Function FillStorage(result As Object, drive As String) As Boolean
|
||||
si = CreateObject("roStorageInfo", drive)
|
||||
if si = invalid then return false
|
||||
total = si.GetSizeInMegabytes()
|
||||
if total = invalid or total <= 0 then return false
|
||||
result.present = true
|
||||
result.volume = drive
|
||||
result.total_mb = total
|
||||
free = si.GetFreeInMegabytes()
|
||||
if free <> invalid and free >= 0 then result.free_mb = free
|
||||
return true
|
||||
End Function
|
||||
|
||||
' Everything the page cannot ask the hardware directly.
|
||||
Sub SendProbeResult(widget As Object)
|
||||
di = CreateObject("roDeviceInfo")
|
||||
|
|
@ -320,6 +534,34 @@ Function FullScreenRect() As Object
|
|||
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
|
||||
End Function
|
||||
|
||||
' Where the SECOND output lives inside the combined canvas, or invalid on a single-output player.
|
||||
'
|
||||
' GetResX/GetResY only ever describe output 1, so they cannot answer this. The per-screen
|
||||
' configuration can: each entry carries display_x/display_y (its origin within the canvas built by
|
||||
' SetScreenModes) and `enabled`. A widget placed at that origin paints that output; there is no
|
||||
' other mechanism, because roHtmlWidget has no output selector.
|
||||
'
|
||||
' Returns invalid unless a SECOND, ENABLED screen genuinely exists — the caller then stays
|
||||
' single-screen and says so, rather than stacking two widgets on output one. The docs warn that
|
||||
' GetScreenModes on a player with unconnected outputs "won't get a valid return" for them, so an
|
||||
' entry that does not describe a real screen is treated as absent.
|
||||
Function SecondScreenRect() As Object
|
||||
if not HasFindMember() then return invalid
|
||||
vm = CreateObject("roVideoMode")
|
||||
if vm = invalid then return invalid
|
||||
if FindMemberFunction(vm, "GetScreenModes") = invalid then return invalid
|
||||
|
||||
configs = vm.GetScreenModes()
|
||||
if configs = invalid or configs.Count() < 2 then return invalid
|
||||
|
||||
s = configs[1]
|
||||
if s = invalid then return invalid
|
||||
if s.enabled <> invalid and s.enabled = false then return invalid
|
||||
if s.display_x = invalid or s.display_y = invalid then return invalid
|
||||
|
||||
return CreateObject("roRectangle", s.display_x, s.display_y, vm.GetResX(), vm.GetResY())
|
||||
End Function
|
||||
|
||||
'=== self-update ============================================================================
|
||||
'
|
||||
' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the
|
||||
|
|
@ -349,9 +591,34 @@ Function PackageVersion() As String
|
|||
return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand)
|
||||
End Function
|
||||
|
||||
Function DoesFileExist(filePath$ As String) As Boolean
|
||||
files = MatchFiles(filePath$, filePath$)
|
||||
return files.Count() > 0
|
||||
' Can this player use FindMemberFunction() at all?
|
||||
'
|
||||
' ⚠️ It is NOT unconditionally available: "It is only available if
|
||||
' roDeviceInfo.HasFeature("FindMemberFunction") returns true." Calling it on a player without the
|
||||
' feature is a runtime error — and both call sites are reached FROM THE EVENT LOOP (the capability
|
||||
' probe on every boot, the storage figures in host telemetry every 60 seconds), so on such a player
|
||||
' the host script would die within a minute of starting and take the display with it. The guard it
|
||||
' was being used AS is the thing that needed guarding.
|
||||
Function HasFindMember() As Boolean
|
||||
di = CreateObject("roDeviceInfo")
|
||||
if di = invalid then return false
|
||||
return di.HasFeature("FindMemberFunction")
|
||||
End Function
|
||||
|
||||
' Does [path] exist?
|
||||
'
|
||||
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
|
||||
' published autozip.brs). It takes a FULL PATH, which is what every call site naturally has.
|
||||
'
|
||||
' MatchFiles is deliberately not used here. It is for LISTING a directory: it takes a directory plus
|
||||
' a pattern, returns nothing when the pattern contains a separator, and — as this player
|
||||
' demonstrated — does not reliably answer for a volume root like "SSD:/". The first version of this
|
||||
' function passed a path as both arguments and could never return true at all; the second passed a
|
||||
' directory and a bare name and still answered "no" for a file sitting right there. An existence
|
||||
' check that is subtly wrong is worse than none, because every guard built on it silently opens.
|
||||
Function FileExists(path As String) As Boolean
|
||||
f = CreateObject("roReadFile", path)
|
||||
return type(f) = "roReadFile"
|
||||
End Function
|
||||
|
||||
' Unpack a package that is sitting on storage waiting to be applied. Runs BEFORE the widget so a
|
||||
|
|
@ -361,40 +628,62 @@ End Function
|
|||
' card holds nothing but autorun.zip and the OS processes it. Once autorun.brs exists at the
|
||||
' storage root the OS no longer auto-processes the archive — so from then on the host has to do it
|
||||
' itself, or self-update would work exactly once.
|
||||
Sub ApplyPendingPackage(root As String)
|
||||
Sub ApplyPendingPackage(root As String, buf As Object)
|
||||
dir$ = root + "/"
|
||||
zipPath$ = root + "/autorun.zip"
|
||||
donePath$ = root + "/autorun.zip.done"
|
||||
badPath$ = root + "/autorun.zip.bad"
|
||||
stage$ = root + "/st-staging"
|
||||
|
||||
if not DoesFileExist(zipPath$) then return
|
||||
if DoesFileExist(donePath$) then return ' already unpacked; extracting again is the boot loop
|
||||
if not FileExists(dir$ + "autorun.zip") then return
|
||||
if FileExists(dir$ + "autorun.zip.done") then return ' already unpacked; again is the boot loop
|
||||
|
||||
print "[st-update] unpacking pending package"
|
||||
LogTo(buf, "update", "unpacking pending package")
|
||||
|
||||
package = CreateObject("roBrightPackage", zipPath$)
|
||||
if package = invalid then
|
||||
print "[st-update] ERROR: archive unreadable (is it STORED?) — parking it as .bad"
|
||||
fs = CreateObject("roFileSystem")
|
||||
if fs <> invalid then fs.Rename(zipPath$, badPath$)
|
||||
LogTo(buf, "update", "ERROR: archive unreadable — parking it as .bad")
|
||||
MoveFile(zipPath$, badPath$)
|
||||
return
|
||||
end if
|
||||
|
||||
if not package.Unpack(root + "/") then
|
||||
print "[st-update] ERROR: extract failed — parking it as .bad so we do not retry forever"
|
||||
fs = CreateObject("roFileSystem")
|
||||
if fs <> invalid then fs.Rename(zipPath$, badPath$)
|
||||
' ⚠️ Unpack() DELETES everything already in its target directory: "Providing a destination path
|
||||
' of SD:/ will wipe all preexisting files from the card". Unpacking straight to the volume root
|
||||
' would therefore erase this player's provisioning and its entire content pool on every update —
|
||||
' the update would work and the display would come back empty and unpaired.
|
||||
'
|
||||
' So it goes to a staging directory of its own, and the files are moved into place afterwards.
|
||||
' The wipe is then a FEATURE: it clears any half-extracted remains of a previous attempt.
|
||||
CreateDirectory(stage$)
|
||||
package.Unpack(stage$ + "/")
|
||||
|
||||
' Unpack() returns Void, so success is proven by looking for what should now exist rather than
|
||||
' by testing a return value that was never there.
|
||||
if not FileExists(stage$ + "/autorun.brs") then
|
||||
LogTo(buf, "update", "ERROR: extract produced no autorun.brs — parking it as .bad")
|
||||
MoveFile(zipPath$, badPath$)
|
||||
return
|
||||
end if
|
||||
|
||||
fs = CreateObject("roFileSystem")
|
||||
if fs = invalid then return
|
||||
if not fs.Rename(zipPath$, donePath$) then
|
||||
' screentinker.json is deliberately NOT copied over: it carries THIS player's provisioning
|
||||
' (server URL, device id), and the copy inside a package carries the build's defaults. Letting
|
||||
' an update overwrite it would re-point or unpair the display as a side effect of a routine
|
||||
' upgrade — silently, and on every player at once.
|
||||
moved% = 0
|
||||
for each name in MatchFiles(stage$, "*")
|
||||
if name <> "screentinker.json" then
|
||||
if MoveFile(stage$ + "/" + name, root + "/" + name) then moved% = moved% + 1
|
||||
end if
|
||||
end for
|
||||
LogTo(buf, "update", "installed " + Stri(moved%).Trim() + " file(s)")
|
||||
|
||||
if not MoveFile(zipPath$, donePath$) then
|
||||
' Refusing to reboot without the marker: we would extract and reboot forever.
|
||||
print "[st-update] ERROR: could not mark done — not rebooting"
|
||||
LogTo(buf, "update", "ERROR: could not mark done — not rebooting")
|
||||
return
|
||||
end if
|
||||
|
||||
print "[st-update] package applied — rebooting into it"
|
||||
LogTo(buf, "update", "package applied — rebooting into it")
|
||||
sleep(2000)
|
||||
RebootSystem()
|
||||
End Sub
|
||||
|
|
@ -405,6 +694,15 @@ End Sub
|
|||
Sub CheckPackageUpdate(cfg As Object, root As String)
|
||||
if cfg.server_url = "" then return
|
||||
|
||||
' A package already staged and waiting for its apply-reboot is not a reason to fetch another.
|
||||
' Observed on hardware: the periodic check fired in the gap between staging and rebooting and
|
||||
' pulled the whole archive down a second time. Harmless here; on a metered or marginal link it
|
||||
' is the same waste this product spent a release eliminating everywhere else.
|
||||
if FileExists(root + "/autorun.zip") then
|
||||
print "[st-update] a package is already staged — waiting for it to apply"
|
||||
return
|
||||
end if
|
||||
|
||||
partPath$ = root + "/autorun.zip.part"
|
||||
reg = CreateObject("roRegistrySection", "screentinker")
|
||||
attempts% = 0
|
||||
|
|
@ -419,7 +717,7 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
|
|||
xfer.SetUrl(url$)
|
||||
xfer.EnablePeerVerification(true)
|
||||
body$ = xfer.GetToString()
|
||||
if body$ = "" then return ' unreachable: keep running what works
|
||||
if body$ = "" then return ' unreachable server: keep running what works
|
||||
|
||||
manifest = ParseJson(body$)
|
||||
if manifest = invalid then return
|
||||
|
|
@ -429,12 +727,22 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
|
|||
return
|
||||
end if
|
||||
|
||||
' Guard the manifest HERE, at the call site, because that is the only place a guard can help.
|
||||
' VerifyPackage takes `As String` / `As Integer` parameters, and a missing key is `invalid`:
|
||||
' handing invalid to a typed parameter is a runtime error raised at the CALL, before a single
|
||||
' line inside the function runs. The check inside VerifyPackage reads like it covers this and
|
||||
' cannot — the script would already have aborted, from inside the event loop, taking playback
|
||||
' down with it. url is checked for the same reason (it is concatenated into a `As String`).
|
||||
if manifest.url = invalid or manifest.sha256 = invalid or manifest.size = invalid then
|
||||
print "[st-update] manifest says download but is missing url/sha256/size — ignoring it"
|
||||
return
|
||||
end if
|
||||
|
||||
print "[st-update] downloading package "; manifest.version
|
||||
|
||||
' Any earlier partial is deleted first: resuming into an existing file would concatenate two
|
||||
' downloads into something that hashes to neither.
|
||||
fs = CreateObject("roFileSystem")
|
||||
if fs <> invalid and DoesFileExist(partPath$) then fs.Delete(partPath$)
|
||||
if FileExists(root + "/autorun.zip.part") then DeleteFile(partPath$)
|
||||
|
||||
dl = CreateObject("roUrlTransfer")
|
||||
if dl = invalid then return
|
||||
|
|
@ -443,7 +751,7 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
|
|||
if dl.GetToFile(partPath$) <> 200 then
|
||||
print "[st-update] download failed"
|
||||
RecordPackageAttempt(reg, attempts% + 1)
|
||||
if fs <> invalid then fs.Delete(partPath$)
|
||||
DeleteFile(partPath$)
|
||||
return
|
||||
end if
|
||||
|
||||
|
|
@ -451,15 +759,14 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
|
|||
if not VerifyPackage(partPath$, manifest.sha256, manifest.size) then
|
||||
print "[st-update] VERIFICATION FAILED — discarding, staying on "; PackageVersion()
|
||||
RecordPackageAttempt(reg, attempts% + 1)
|
||||
if fs <> invalid then fs.Delete(partPath$)
|
||||
DeleteFile(partPath$)
|
||||
return
|
||||
end if
|
||||
|
||||
' Promote. Marker first — see the ordering note above.
|
||||
if fs = invalid then return
|
||||
if DoesFileExist(root + "/autorun.zip.done") then fs.Delete(root + "/autorun.zip.done")
|
||||
if DoesFileExist(root + "/autorun.zip") then fs.Delete(root + "/autorun.zip")
|
||||
if not fs.Rename(partPath$, root + "/autorun.zip") then
|
||||
if FileExists(root + "/autorun.zip.done") then DeleteFile(root + "/autorun.zip.done")
|
||||
if FileExists(root + "/autorun.zip") then DeleteFile(root + "/autorun.zip")
|
||||
if not MoveFile(partPath$, root + "/autorun.zip") then
|
||||
print "[st-update] ERROR: could not stage the package — staying put"
|
||||
RecordPackageAttempt(reg, attempts% + 1)
|
||||
return
|
||||
|
|
@ -482,41 +789,177 @@ End Sub
|
|||
' sha256 + size. Both matter: the hash proves the bytes are the ones we were promised, the size
|
||||
' floor catches an error page or captive-portal login saved under the package's name.
|
||||
Function VerifyPackage(path As String, expected As String, expectedSize As Integer) As Boolean
|
||||
if expected = invalid or expected = "" then return false
|
||||
' Guard the arguments before the type declarations do it for us: a manifest missing sha256 or
|
||||
' size passes `invalid` into an `As String`/`As Integer` parameter, which is a runtime error at
|
||||
' the CALL — before any check inside the function could help.
|
||||
if expected = "" then return false
|
||||
|
||||
fs = CreateObject("roFileSystem")
|
||||
if fs = invalid then return false
|
||||
|
||||
info = fs.Stat(path)
|
||||
if info = invalid then return false
|
||||
if info.size < 1024 then
|
||||
print "[st-update] package is implausibly small ("; info.size; " bytes)"
|
||||
return false
|
||||
end if
|
||||
if expectedSize > 0 and info.size <> expectedSize then
|
||||
print "[st-update] size mismatch: got "; info.size; " expected "; expectedSize
|
||||
' roByteArray + roHashGenerator. The previous version used roFileSystem.Stat/OpenInputFile and
|
||||
' roMessageDigest — all three are Roku objects that do not exist on BrightSign, so this function
|
||||
' returned false unconditionally and every self-update failed verification and burned an
|
||||
' attempt. The package is tens of kilobytes, so reading it whole is cheaper than the streaming
|
||||
' loop it replaces.
|
||||
ba = CreateObject("roByteArray")
|
||||
if ba = invalid then return false
|
||||
if not ba.ReadFile(path) then
|
||||
print "[st-update] package unreadable at "; path
|
||||
return false
|
||||
end if
|
||||
|
||||
digest = CreateObject("roMessageDigest")
|
||||
size% = ba.Count()
|
||||
if size% < 1024 then
|
||||
print "[st-update] package is implausibly small ("; size%; " bytes)"
|
||||
return false
|
||||
end if
|
||||
if expectedSize > 0 and size% <> expectedSize then
|
||||
print "[st-update] size mismatch: got "; size%; " expected "; expectedSize
|
||||
return false
|
||||
end if
|
||||
|
||||
hg = CreateObject("roHashGenerator", "sha256")
|
||||
if hg = invalid then return false
|
||||
digest = hg.Hash(ba)
|
||||
if digest = invalid then return false
|
||||
digest.SetAlgorithm("sha256")
|
||||
|
||||
file = fs.OpenInputFile(path)
|
||||
if file = invalid then return false
|
||||
while true
|
||||
chunk = file.Read(65536)
|
||||
if chunk.Count() = 0 then exit while
|
||||
digest.Update(chunk)
|
||||
end while
|
||||
|
||||
return LCase(digest.Final()) = LCase(expected)
|
||||
' Hash() answers with an roByteArray, not a string.
|
||||
return LCase(digest.ToHexString()) = LCase(expected)
|
||||
End Function
|
||||
|
||||
'=== host diagnostics =======================================================================
|
||||
'
|
||||
' Everything the host knows that the PAGE cannot ask for, routed into the same channels the other
|
||||
' players already use: the dashboard log stream, the device-event feed, and the heartbeat telemetry.
|
||||
'
|
||||
' This exists because of a specific, expensive afternoon. A single bad string literal stopped this
|
||||
' script compiling, and the only evidence anywhere was a line on a serial console — the server saw a
|
||||
' player that simply never appeared, and the display showed nothing. Every other player reports its
|
||||
' own failures; this one printed them to a cable. A panel on a wall has no cable.
|
||||
'
|
||||
' The pre-widget phase is the part that matters most and is the part that is hardest to reach: the
|
||||
' storage probe, a pending package being applied, the video mode being set, all happen before there
|
||||
' is a page to talk to. Those lines accumulate in a buffer and are flushed the moment the widget
|
||||
' exists, so the boot story arrives even though it happened before anyone could listen.
|
||||
|
||||
' Append a diagnostic to the pre-widget buffer AND put it on the console. The buffer is an roArray
|
||||
' created in Main and passed down; BrightScript has no global store (no GetGlobalAA here), and
|
||||
' threading it explicitly beats the alternative of losing the boot entirely.
|
||||
Sub LogTo(buf As Object, tag As String, message As String)
|
||||
print "[st-"; tag; "] "; message
|
||||
if buf <> invalid then
|
||||
if buf.Count() < 200 then ' a boot that logs 200 lines has a worse problem
|
||||
buf.Push({ tag: tag, message: message })
|
||||
end if
|
||||
end if
|
||||
End Sub
|
||||
|
||||
' Send one diagnostic to the page, which forwards it to the server as a device:log line.
|
||||
Sub HostLog(widget As Object, tag As String, message As String)
|
||||
print "[st-"; tag; "] "; message
|
||||
if widget = invalid then return
|
||||
widget.PostJSMessage({ type: "host-log", tag: tag, level: "i", message: message })
|
||||
End Sub
|
||||
|
||||
' Hand the buffered boot diagnostics to the page in one go, oldest first.
|
||||
Sub FlushLog(widget As Object, buf As Object)
|
||||
if widget = invalid or buf = invalid then return
|
||||
for each line in buf
|
||||
widget.PostJSMessage({ type: "host-log", tag: line.tag, level: "i", message: line.message })
|
||||
end for
|
||||
buf.Clear()
|
||||
End Sub
|
||||
|
||||
' A device EVENT rather than a log line: these land in the incident feed the dashboard shows against
|
||||
' a display, so they are reserved for things an operator would want explained — a reboot, a network
|
||||
' change, the player falling over.
|
||||
Sub HostEvent(widget As Object, event As String, reason As String, detail As String)
|
||||
print "[st-event] "; event; " "; reason; " "; detail
|
||||
if widget = invalid then return
|
||||
widget.PostJSMessage({ type: "host-event", event: event, reason: reason, detail: detail })
|
||||
End Sub
|
||||
|
||||
' The facts only the host can see. The page has no API for any of this: @brightsign/storage exposes
|
||||
' format and eject, not volumes; there is no JavaScript route to the uptime, the wired IP, the video
|
||||
' mode actually in force, or which volume the player booted from.
|
||||
Sub SendHostTelemetry(widget As Object, cfg As Object)
|
||||
if widget = invalid then return
|
||||
|
||||
t = { type: "host-telemetry" }
|
||||
|
||||
' Seconds since boot. A display that reports a small uptime every time it is polled is
|
||||
' rebooting in a loop, which is otherwise indistinguishable from a healthy one.
|
||||
up = UpTime(0)
|
||||
if up <> invalid then t.uptime_seconds = Int(up)
|
||||
|
||||
di = CreateObject("roDeviceInfo")
|
||||
if di <> invalid then
|
||||
t.model = di.GetModel()
|
||||
t.os_version = di.GetVersion()
|
||||
end if
|
||||
|
||||
' The address this player holds on the LAN — the one an integrator needs to reach its DWS on
|
||||
' site, and the one the dashboard has never been able to show for a BrightSign.
|
||||
'
|
||||
' Interface 0 alone was not enough. Our XT245 produced 6000 telemetry rows with local_ip NULL
|
||||
' while sitting on a healthy PoE network with a perfectly good address, and every other field in
|
||||
' this same payload arrived. So try each interface the platform documents rather than assuming
|
||||
' the first one answers: 0/"eth0" is the Ethernet port, "eth1" the control port on players that
|
||||
' have one, 1/"wlan0" the internal WiFi.
|
||||
'
|
||||
' ⚠️ The STRING forms matter. Per the Object Reference, an INTEGER interface "must currently
|
||||
' exist on the player; otherwise the object-creation function will return Invalid" — the string
|
||||
' names carry no such condition, so they are the ones that answer when the integer does not.
|
||||
'
|
||||
' NOT roDeviceInfo.GetIPAddrs(): that is Roku's API. BrightSign's roDeviceInfo has no network
|
||||
' method at all, and calling it would raise "Member function not found" here every minute. This
|
||||
' is the exact family of mistake server/test/brightscript-api-surface.test.js exists to catch.
|
||||
' The list is mixed integer/string on purpose (see above) and is walked in full rather than with
|
||||
' an early exit, because the guard is the same either way and a typed `exit for` inside a nested
|
||||
' if is exactly the sort of thing that cannot be checked from this repo.
|
||||
for each iface in [0, "eth0", "eth1", 1, "wlan0"]
|
||||
if t.local_ip = invalid then
|
||||
nc = CreateObject("roNetworkConfiguration", iface)
|
||||
if nc <> invalid then
|
||||
cur = nc.GetCurrentConfig()
|
||||
if cur <> invalid and cur.ip4_address <> invalid and cur.ip4_address <> "" then
|
||||
t.local_ip = cur.ip4_address
|
||||
end if
|
||||
end if
|
||||
end if
|
||||
end for
|
||||
|
||||
' Say so when nothing answered. Silence here is what made this invisible for a whole fleet: the
|
||||
' field simply stayed NULL and looked like a server-side gap rather than a player that never
|
||||
' sent it. One line on the host log costs nothing and names the real state.
|
||||
if t.local_ip = invalid then
|
||||
HostLog(widget, "net", "no ip4_address on any interface (0/eth0/eth1/1/wlan0)")
|
||||
end if
|
||||
|
||||
vm = CreateObject("roVideoMode")
|
||||
if vm <> invalid then t.video_mode = vm.GetMode()
|
||||
|
||||
' Which volume is actually in use, and how much of it is left. The page's
|
||||
' navigator.storage.estimate() reports the widget's CACHE QUOTA, not the disk — a panel can
|
||||
' report gigabytes free while the volume holding them is full.
|
||||
st = StorageProbe()
|
||||
if st.present then
|
||||
t.storage_volume = st.volume
|
||||
t.storage_free_mb = st.free_mb
|
||||
t.storage_total_mb = st.total_mb
|
||||
end if
|
||||
t.boot_volume = StorageRoot()
|
||||
t.package_version = PackageVersion()
|
||||
|
||||
widget.PostJSMessage(t)
|
||||
End Sub
|
||||
|
||||
'=== main ===================================================================================
|
||||
|
||||
Sub Main()
|
||||
' Diagnostics from before there is a page to send them to. Flushed the moment the widget exists.
|
||||
boot = CreateObject("roArray", 32, true)
|
||||
|
||||
cfg = LoadConfig()
|
||||
LogTo(boot, "boot", "host " + PackageVersion() + " from " + StorageRoot() + " -> " + cfg.server_url)
|
||||
|
||||
' Crash dumps land here if the widget ever falls over — cheap, and the only forensic trail
|
||||
' available on a panel nobody can reach.
|
||||
|
|
@ -528,7 +971,7 @@ Sub Main()
|
|||
' A package staged by a previous run lands here, before anything is on screen. Doing it after
|
||||
' the widget started would mean rebooting out of a playing playlist, and the panel would blink
|
||||
' mid-content for a reason nobody watching could explain.
|
||||
ApplyPendingPackage(StorageRoot())
|
||||
ApplyPendingPackage(StorageRoot(), boot)
|
||||
|
||||
port = CreateObject("roMessagePort")
|
||||
|
||||
|
|
@ -548,12 +991,37 @@ Sub Main()
|
|||
widget = MakeWidget(PlayerUrl(cfg, 1), rect, port, cfg)
|
||||
widget.Show()
|
||||
|
||||
' NOT flushed here. Show() only creates the widget — the page has not been fetched, let alone
|
||||
' run st-bridge.js, so there is nothing on the other end of PostJSMessage yet and every line
|
||||
' would go into the void. Buffered instead until the page says hello (its `probe` message,
|
||||
' which st-bridge.js posts as soon as it loads), which is the whole reason the buffer exists.
|
||||
' The same window ate SendHostTelemetry; telemetry repeats every 60s so it self-healed and the
|
||||
' boot report — the one that only ever happens once — did not.
|
||||
widget2 = invalid
|
||||
if dual then
|
||||
screen2 = 2
|
||||
if cfg.output_mode = "clone" then screen2 = 1
|
||||
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect, port, cfg)
|
||||
if widget2 <> invalid then widget2.Show()
|
||||
' ⚠️ There is NO per-widget output selector. roHtmlWidget takes a rectangle and nothing else:
|
||||
' its init parameters have no `screen`/`output` key, and neither does the JavaScript
|
||||
' HtmlWidgetParams. A second output is addressed by BUILDING ONE TALL CANVAS with
|
||||
' SetScreenModes (display_x/display_y stack the outputs) and then placing the second widget
|
||||
' at that offset inside it.
|
||||
'
|
||||
' Which means the previous version could not work: it passed the SAME full-screen rect for
|
||||
' both widgets, so widget 2 was composited directly on top of widget 1 on output ONE — two
|
||||
' players fighting over one screen while the second output stayed dark. "dual" and "clone"
|
||||
' were configuration options that made the display worse and reported nothing.
|
||||
rect2 = SecondScreenRect()
|
||||
if rect2 = invalid then
|
||||
' Refused rather than guessed. Multi-output is documented for the XC2055 (two) and
|
||||
' XC4055 (four); the XT line has HDMI IN and HDMI OUT, which the series blurb describes
|
||||
' as "dual HDMI" and which is not a second output at all.
|
||||
LogTo(boot, "boot", "output_mode=" + cfg.output_mode + " but this player exposes one output — staying single-screen")
|
||||
HostEvent(widget, "app_error", "output-mode", "dual/clone requested; this player has a single output")
|
||||
else
|
||||
screen2 = 2
|
||||
if cfg.output_mode = "clone" then screen2 = 1
|
||||
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect2, port, cfg)
|
||||
if widget2 <> invalid then widget2.Show()
|
||||
end if
|
||||
end if
|
||||
|
||||
retries = 0
|
||||
|
|
@ -573,7 +1041,14 @@ Sub Main()
|
|||
' exception, decoder stall) without the OS ever reporting an error. st-bridge.js posts a
|
||||
' heartbeat every 30s; three missed beats and we rebuild the widget. This is the difference
|
||||
' between a panel that recovers on its own and one that needs a site visit.
|
||||
WATCHDOG_MS = 120000
|
||||
' Seconds first, milliseconds derived: the diagnostic message needs an INTEGER to format, and
|
||||
' dividing at the call site would hand Stri a float.
|
||||
WATCHDOG_S = 120
|
||||
WATCHDOG_MS = WATCHDOG_S * 1000
|
||||
|
||||
lastHostTel = CreateObject("roTimespan")
|
||||
lastHostTel.Mark()
|
||||
HOST_TEL_MS = 60000
|
||||
|
||||
while true
|
||||
msg = wait(5000, port)
|
||||
|
|
@ -590,7 +1065,15 @@ Sub Main()
|
|||
' Back off, then fall back to the local page so the screen says something
|
||||
' truthful instead of showing white. The local page keeps retrying the server.
|
||||
retries = retries + 1
|
||||
print "[st] load-error ("; retries; "): "; data.url
|
||||
' The key is `uri` on a load-error; `url` belongs to download-request. Printing
|
||||
' the wrong one meant the single diagnostic that names the failing resource always
|
||||
' printed "invalid".
|
||||
' data.uri is already a String per the event contract, so no conversion is wanted:
|
||||
' Str() is for numbers and would abort the event loop. Guarded because a missing key
|
||||
' yields invalid, and assigning invalid to a $-typed name is a runtime error.
|
||||
uri$ = ""
|
||||
if data.uri <> invalid then uri$ = data.uri
|
||||
HostEvent(widget, "app_error", "load-error", "attempt " + Stri(retries).Trim() + ": " + uri$)
|
||||
sleep(ChooseBackoff(retries))
|
||||
if retries >= 3 then
|
||||
' The server URL rides along so the fallback page can name it on screen and
|
||||
|
|
@ -639,6 +1122,11 @@ Sub Main()
|
|||
' Asked once during boot, before the player registers: the answer decides which
|
||||
' controls the dashboard is allowed to offer for this display.
|
||||
SendProbeResult(widget)
|
||||
' ...and this is the first PROOF that a page is listening, so it is the earliest
|
||||
' moment the buffered boot story can actually be delivered. st-bridge.js holds it
|
||||
' until the player's socket is up, so late here is still in time.
|
||||
FlushLog(widget, boot)
|
||||
SendHostTelemetry(widget, cfg)
|
||||
|
||||
else if m.type = "set-orientation" then
|
||||
if m.orientation <> invalid then SetOrientation(widget, m.orientation)
|
||||
|
|
@ -668,11 +1156,21 @@ Sub Main()
|
|||
|
||||
' watchdog
|
||||
if lastBeat.TotalMilliseconds() > WATCHDOG_MS then
|
||||
print "[st] watchdog: no heartbeat in "; WATCHDOG_MS; "ms — rebuilding widget"
|
||||
' Reported as a crash, because that is what it is from the floor: the page stopped
|
||||
' answering and the host restarted it. Previously this healed the panel in silence, so a
|
||||
' display rebuilding itself every two minutes looked identical to one that was fine.
|
||||
HostEvent(widget, "crash", "watchdog", "no heartbeat for " + Stri(WATCHDOG_S).Trim() + "s — rebuilt the widget")
|
||||
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
|
||||
lastBeat.Mark()
|
||||
end if
|
||||
|
||||
' Host facts, on the same cadence as the package check is cheap but far too slow to be
|
||||
' useful; every telemetry tick would be too chatty. A minute is what the dashboard shows.
|
||||
if lastHostTel.TotalMilliseconds() > HOST_TEL_MS then
|
||||
lastHostTel.Mark()
|
||||
SendHostTelemetry(widget, cfg)
|
||||
end if
|
||||
|
||||
' Periodic package check. Marked BEFORE the call, not after: a check that blocks on a slow
|
||||
' server would otherwise be retried immediately on the next tick and hammer it.
|
||||
if cfg.self_update and lastPkgCheck.TotalMilliseconds() > PKG_CHECK_MS then
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@
|
|||
' being processed at all. autorun.brs belongs INSIDE the zip, which is where the build script puts
|
||||
' it (scripts/build-autorun-zip.sh).
|
||||
'
|
||||
' Unpacks with roBrightPackage, which is what BrightSign's own tooling uses — NOT roUnzip.
|
||||
' A BrightSign consultant flagged this after our first archive failed his automated deployment:
|
||||
' the zip reached the player and then could not be opened. Two causes, both fixed:
|
||||
' - the archive must be STORED, no compression (scripts/build-autorun-zip.sh now asserts it)
|
||||
' - roBrightPackage is the supported reader for a player package
|
||||
' Unpacks with roBrightPackage, which is what BrightSign's own tooling uses.
|
||||
'
|
||||
' On compression: roBrightPackage supports deflate32 with default options, PPMd, and "no
|
||||
' compression". What it does NOT support is bzip2, LZMA, Deflate64 and Zip64 (the last being what
|
||||
' Windows Explorer's built-in zipper produces). We build STORED, which is stricter than required and
|
||||
' costs nothing at this size — but note that compression was NOT the cause of the deployment failure
|
||||
' this script was blamed for. That was the MatchFiles bug below.
|
||||
'
|
||||
' Requires BrightSignOS 7.0.60+.
|
||||
|
||||
|
|
@ -26,9 +28,9 @@
|
|||
' for the file beats assuming a volume: extracting to "SD:/" on a player with no card writes to a
|
||||
' volume that does not exist, and the deployment silently does nothing.
|
||||
Function SourceRoot() As String
|
||||
volumes = ["USB1:", "SD:", "SSD:", "FLASH:"]
|
||||
volumes = ["USB1:", "SD:", "SD2:", "SSD:", "FLASH:"]
|
||||
for each v in volumes
|
||||
if DoesFileExist(v + "/autorun.zip") then return v
|
||||
if FileExists(v + "/autorun.zip") then return v
|
||||
end for
|
||||
return ""
|
||||
End Function
|
||||
|
|
@ -45,14 +47,14 @@ Sub Main()
|
|||
|
||||
print "[st-autozip] volume "; root$
|
||||
|
||||
if not DoesFileExist(zipPath$) then
|
||||
if not FileExists(extractPath$ + "autorun.zip") then
|
||||
print "[st-autozip] no autorun.zip at "; zipPath$; " — nothing to do"
|
||||
return
|
||||
end if
|
||||
|
||||
' Idempotence. Without this the player extracts, reboots, extracts again, reboots again —
|
||||
' a boot loop that looks like a hardware fault.
|
||||
if DoesFileExist(donePath$) then
|
||||
if FileExists(extractPath$ + "autorun.zip.done") then
|
||||
print "[st-autozip] already unpacked (autorun.zip.done present) — leaving it alone"
|
||||
return
|
||||
end if
|
||||
|
|
@ -67,20 +69,22 @@ Sub Main()
|
|||
return
|
||||
end if
|
||||
|
||||
if not package.Unpack(extractPath$) then
|
||||
print "[st-autozip] ERROR: unpack failed"
|
||||
' Unpack() returns VOID — there is no boolean to test, and `if not package.Unpack(...)` was a
|
||||
' type error rather than an error check. Success is proven the only way that actually means
|
||||
' anything: the file we came here to install is now on the card.
|
||||
package.Unpack(extractPath$)
|
||||
|
||||
if not FileExists(extractPath$ + "autorun.brs") then
|
||||
print "[st-autozip] ERROR: unpack produced no autorun.brs — leaving the archive for a retry"
|
||||
return
|
||||
end if
|
||||
|
||||
print "[st-autozip] extracted"
|
||||
|
||||
fs = CreateObject("roFileSystem")
|
||||
if fs = invalid then
|
||||
print "[st-autozip] ERROR: no roFileSystem — cannot mark the archive done"
|
||||
return
|
||||
end if
|
||||
|
||||
if not fs.Rename(zipPath$, donePath$) then
|
||||
' MoveFile/DeleteFile are GLOBAL functions on BrightSign. roFileSystem is a Roku object and does
|
||||
' not exist here, so every one of these calls used to return invalid — which meant the archive
|
||||
' was never marked done and the player never rebooted into the player it had just installed.
|
||||
if not MoveFile(zipPath$, donePath$) then
|
||||
print "[st-autozip] ERROR: could not rename the archive; refusing to reboot into a loop"
|
||||
return
|
||||
end if
|
||||
|
|
@ -90,7 +94,18 @@ Sub Main()
|
|||
RebootSystem()
|
||||
End Sub
|
||||
|
||||
Function DoesFileExist(filePath$ As String) As Boolean
|
||||
files = MatchFiles(filePath$, filePath$)
|
||||
return files.Count() > 0
|
||||
' Does [path] exist?
|
||||
'
|
||||
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
|
||||
' published autozip.brs). It takes a FULL PATH, which is what every call site naturally has.
|
||||
'
|
||||
' MatchFiles is deliberately not used here. It is for LISTING a directory: it takes a directory plus
|
||||
' a pattern, returns nothing when the pattern contains a separator, and — as this player
|
||||
' demonstrated — does not reliably answer for a volume root like "SSD:/". The first version of this
|
||||
' function passed a path as both arguments and could never return true at all; the second passed a
|
||||
' directory and a bare name and still answered "no" for a file sitting right there. An existence
|
||||
' check that is subtly wrong is worse than none, because every guard built on it silently opens.
|
||||
Function FileExists(path As String) As Boolean
|
||||
f = CreateObject("roReadFile", path)
|
||||
return type(f) = "roReadFile"
|
||||
End Function
|
||||
|
|
|
|||
|
|
@ -35,8 +35,28 @@
|
|||
var MessagePortClass = tryRequire('@brightsign/messageport');
|
||||
var RegistryClass = tryRequire('@brightsign/registry');
|
||||
var DeviceInfoClass = tryRequire('@brightsign/deviceinfo');
|
||||
var VideoOutputClass = tryRequire('@brightsign/videooutput');
|
||||
/*
|
||||
* ⚠️ @brightsign/videooutput does NOT set a video mode. Its surface is read-only plus power
|
||||
* (getVideoResolution / getEdid / isAttached / setPowerSaveMode / setBackgroundColor); there is
|
||||
* no setMode on it at all. Mode setting lives on @brightsign/videomodeconfiguration, whose
|
||||
* setMode() returns a Promise<{restartRequired}>.
|
||||
*
|
||||
* The two were conflated here, and the cost was not a broken call — the call was guarded — it was
|
||||
* a LIE: a widget with no host bridge declared display.resolution purely because videooutput
|
||||
* resolved, and the dashboard grew a resolution control that could never do anything.
|
||||
*/
|
||||
var VideoModeConfigClass = tryRequire('@brightsign/videomodeconfiguration');
|
||||
var CecClass = tryRequire('@brightsign/cec');
|
||||
// Reads the attached display's EDID. Read-only; the mode setter is videomodeconfiguration.
|
||||
var VideoOutputClass = tryRequire('@brightsign/videooutput');
|
||||
/*
|
||||
* Node's standard library, present because the widget is created with nodejs_enabled. Used for
|
||||
* the LAN address (see refreshTelemetry) exactly as BrightSign's own dev-cookbook templates do.
|
||||
* tryRequire, not a bare require: in a plain browser there is no require at all, and this file
|
||||
* must load there too.
|
||||
*/
|
||||
var osModule = tryRequire('os');
|
||||
var fsModule = tryRequire('fs');
|
||||
|
||||
var port = null;
|
||||
if (MessagePortClass) {
|
||||
|
|
@ -230,6 +250,89 @@
|
|||
// its own telemetry object, and a null here would overwrite a value another player family had
|
||||
// legitimately supplied. Absent means "nothing to say", which is not the same as "zero".
|
||||
var telemetry = {};
|
||||
|
||||
/*
|
||||
* Facts pushed by the host, merged into the same cache the heartbeat reads.
|
||||
*
|
||||
* Registered at load, directly on the listener list rather than behind the readiness gate: the
|
||||
* host starts sending these the moment the widget exists, and anything attached later would miss
|
||||
* the boot report — the one that says which volume the player came up from and whether a package
|
||||
* applied.
|
||||
*
|
||||
* The host's numbers WIN over the page's where they overlap. navigator.storage.estimate()
|
||||
* describes the widget's cache quota, not the disk: a panel can report gigabytes free while the
|
||||
* volume holding them is full, and only the host can tell the difference.
|
||||
*/
|
||||
/*
|
||||
* Host diagnostics arrive BEFORE anyone is listening, and that is not an edge case — it is the
|
||||
* normal order of events and the whole reason they are worth carrying.
|
||||
*
|
||||
* The host buffers its pre-widget boot lines and posts them the moment the page says hello. The
|
||||
* player, correctly, does not subscribe until its socket is connected, because a line forwarded
|
||||
* before that has nowhere to go. Between those two facts every boot line was dropped: the host
|
||||
* spoke into a page with no listener, and the listener arrived after the words had gone. The
|
||||
* player's own comment says wiring earlier "would drop the host's boot report on the floor" —
|
||||
* which was true, and left the report on the floor anyway.
|
||||
*
|
||||
* So the bridge holds them. Messages land in these queues from the moment the file loads, and are
|
||||
* replayed to each consumer as it registers. Bounded, because a host stuck in a reboot loop must
|
||||
* not grow this without limit on a player that runs for months.
|
||||
*/
|
||||
var PENDING_MAX = 200;
|
||||
var logSinks = [];
|
||||
var eventSinks = [];
|
||||
var pendingLogs = [];
|
||||
var pendingEvents = [];
|
||||
|
||||
function drain(queue, fn) {
|
||||
// Copied first: fn is free to register another sink, and iterating a live array while it is
|
||||
// being appended to is how a replay turns into a loop.
|
||||
var items = queue.slice();
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
try { fn(items[i]); } catch (e) { /* one bad consumer must not eat the rest of the boot log */ }
|
||||
}
|
||||
}
|
||||
|
||||
function fanout(sinks, queue, payload) {
|
||||
if (sinks.length === 0) {
|
||||
if (queue.length < PENDING_MAX) queue.push(payload);
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < sinks.length; i++) {
|
||||
try { sinks[i](payload); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
listeners.push(function (msg) {
|
||||
if (!msg) return;
|
||||
if (msg.type === 'host-log') {
|
||||
fanout(logSinks, pendingLogs, {
|
||||
tag: String(msg.tag || 'host').slice(0, 64),
|
||||
level: String(msg.level || 'i').slice(0, 8),
|
||||
message: String(msg.message || '').slice(0, 2000)
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'host-event' && msg.event) {
|
||||
fanout(eventSinks, pendingEvents, {
|
||||
event: String(msg.event),
|
||||
reason: String(msg.reason || '').slice(0, 64),
|
||||
detail: String(msg.detail || '').slice(0, 500)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
listeners.push(function (msg) {
|
||||
if (msg && msg.type === 'host-telemetry') {
|
||||
var keys = ['uptime_seconds', 'local_ip', 'model', 'os_version', 'video_mode',
|
||||
'storage_volume', 'storage_free_mb', 'storage_total_mb',
|
||||
'boot_volume', 'package_version'];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var v = msg[keys[i]];
|
||||
if (v !== undefined && v !== null && v !== '') telemetry[keys[i]] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
var TELEMETRY_REFRESH_MS = 60000;
|
||||
|
||||
var deviceInfo = null;
|
||||
|
|
@ -303,10 +406,21 @@
|
|||
*/
|
||||
add('playback.transitions'); add('playback.pip');
|
||||
|
||||
// Service-worker content caching. The quota is configured in autorun.brs (storage_path +
|
||||
// storage_quota); without a service worker there is no offline story at all.
|
||||
/*
|
||||
* Service-worker content caching — and this platform does not have it.
|
||||
*
|
||||
* `navigator.serviceWorker` EXISTS on a BrightSign widget and is not usable: our XT245 on alpha
|
||||
* passes this exact check, then never even fetches sw.js. Presence was therefore the one signal
|
||||
* that could not distinguish "caches offline" from "cannot", and it answered yes to both — the
|
||||
* player advertised offline.cache to the whole fleet while being unable to hold a single byte
|
||||
* through an outage. The web player already learned this (it waits for a worker that is in
|
||||
* CONTROL, see declareCapabilities in server/player/index.html); this copy had not.
|
||||
*
|
||||
* A controller is proof, not a promise: something is actually intercepting this page's fetches.
|
||||
*/
|
||||
try {
|
||||
if (global.navigator && global.navigator.serviceWorker) add('offline.cache');
|
||||
var sw = global.navigator && global.navigator.serviceWorker;
|
||||
if (sw && sw.controller) add('offline.cache');
|
||||
} catch (e) { /* no SW in this widget */ }
|
||||
|
||||
// ---- needs the host bridge --------------------------------------------------------------
|
||||
|
|
@ -318,8 +432,8 @@
|
|||
add('system.reboot'); // RebootSystem
|
||||
add('display.rotation'); // roVideoMode transform — the ONLY way video rotates here
|
||||
add('display.resolution'); // roVideoMode SetMode
|
||||
} else if (VideoOutputClass) {
|
||||
// No host, but the JS video-output module resolved: resolution alone is still reachable.
|
||||
} else if (VideoModeConfigClass) {
|
||||
// No host, but the JS mode-configuration module resolved: resolution alone is still reachable.
|
||||
add('display.resolution');
|
||||
}
|
||||
|
||||
|
|
@ -396,7 +510,12 @@
|
|||
serial: function () {
|
||||
if (deviceInfo) {
|
||||
try {
|
||||
var s = deviceInfo.serialNumber || (deviceInfo.getDeviceUniqueId && deviceInfo.getDeviceUniqueId());
|
||||
// `serialNumber` is the whole answer. There is no getDeviceUniqueId() on
|
||||
// @brightsign/deviceinfo — that is the BrightScript roDeviceInfo method name, and
|
||||
// BrightSign's own migration note maps it to this attribute. `deviceUniqueId` is the
|
||||
// legacy BSDeviceInfo global's spelling, also an attribute rather than a call, and is
|
||||
// read here only so a very old widget build still answers with something.
|
||||
var s = deviceInfo.serialNumber || deviceInfo.deviceUniqueId;
|
||||
if (s) return String(s);
|
||||
} catch (e) { /* fall through to the URL */ }
|
||||
}
|
||||
|
|
@ -532,10 +651,17 @@
|
|||
},
|
||||
|
||||
setVideoMode: function (mode) {
|
||||
if (VideoOutputClass) {
|
||||
if (VideoModeConfigClass) {
|
||||
try {
|
||||
var vo = new VideoOutputClass();
|
||||
if (vo && typeof vo.setMode === 'function') { vo.setMode(mode); return true; }
|
||||
var vmc = new VideoModeConfigClass();
|
||||
if (vmc && typeof vmc.setMode === 'function') {
|
||||
// Promise<{restartRequired}>. Nothing here awaits it — a mode change that restarts the
|
||||
// application takes this page with it, so there is no "after" to report into. Rejection
|
||||
// is swallowed rather than left as an unhandled rejection on a signage player.
|
||||
var r = vmc.setMode(mode);
|
||||
if (r && typeof r.catch === 'function') r.catch(function () {});
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* fall back to the host */ }
|
||||
}
|
||||
return post({ type: 'set-video-mode', mode: mode });
|
||||
|
|
@ -553,6 +679,89 @@
|
|||
* DWS writes the full capture to disk before returning a thumbnail), the caller gets a reason
|
||||
* it can show instead of a spinner that never resolves.
|
||||
*/
|
||||
/*
|
||||
* Capture the screen using BrightSign's OWN screenshot API — the composite of the video and
|
||||
* graphics layers, which is the whole point: an in-page canvas cannot read the hardware video
|
||||
* plane, so a DOM composite returns a frame with the content missing.
|
||||
*
|
||||
* Entirely page-side, and that is what makes it work here. The obvious route was to ask the
|
||||
* host (BrightScript) to capture via the player's DWS, but page->host messaging is dead after
|
||||
* load on this platform, so the request never arrived. `@brightsign/screenshot` needs no host,
|
||||
* no DWS, no messageport — just the Node `require` the widget already has (the same one that
|
||||
* makes `module` visible to classic scripts).
|
||||
*
|
||||
* The API writes a FILE rather than returning bytes, so it is read straight back with Node's
|
||||
* fs — available for exactly the same reason require() is.
|
||||
*/
|
||||
captureScreen: function (opts) {
|
||||
var o = opts || {};
|
||||
return new Promise(function (resolve, reject) {
|
||||
var ScreenshotClass = tryRequire('@brightsign/screenshot');
|
||||
var fs = tryRequire('fs');
|
||||
if (!ScreenshotClass) { reject(new Error('no @brightsign/screenshot module')); return; }
|
||||
if (!fs) { reject(new Error('no fs module')); return; }
|
||||
|
||||
// RAM FIRST, deliberately. The remote-control view drives this once a second, and a
|
||||
// screenshot per second written to the boot flash is a wear-out mechanism with no upside —
|
||||
// the file is read back and deleted microseconds later, so it never needs to be durable.
|
||||
// BrightSign exposes tmp as a RAM volume alongside the storage ones. Real storage is only
|
||||
// a fallback for a unit that does not present tmp, and the directory must already exist or
|
||||
// the capture fails, so each candidate is checked rather than assumed.
|
||||
var dirs = ['/storage/tmp', '/tmp', '/storage/ssd', '/storage/usb1', '/storage/sd', '/storage/flash'];
|
||||
var dir = null;
|
||||
for (var i = 0; i < dirs.length; i++) {
|
||||
try { if (fs.existsSync(dirs[i])) { dir = dirs[i]; break; } } catch (e) { /* keep looking */ }
|
||||
}
|
||||
if (!dir) { reject(new Error('no writable volume for the capture')); return; }
|
||||
|
||||
var path = dir + '/st-capture.jpg';
|
||||
try { fs.unlinkSync(path); } catch (e) { /* first run, or already gone */ }
|
||||
|
||||
var params = {
|
||||
destinationFileName: path,
|
||||
fileName: path, // deprecated alias, still honoured on older firmware
|
||||
fileType: 'JPEG',
|
||||
width: o.width || 960,
|
||||
height: o.height || 540,
|
||||
quality: o.quality || 70,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
var shot;
|
||||
try { shot = new ScreenshotClass(); } catch (e) { reject(new Error('screenshot object: ' + e.message)); return; }
|
||||
|
||||
try {
|
||||
// syncCapture may interrupt on-screen operations, which the docs flag as a debugging
|
||||
// trait — but it guarantees the file exists when it returns, and an operator asking for
|
||||
// one screenshot is worth a single frame of interruption. The stream path uses async.
|
||||
if (o.async && typeof shot.asyncCapture === 'function') shot.asyncCapture(params);
|
||||
else if (typeof shot.syncCapture === 'function') shot.syncCapture(params);
|
||||
else if (typeof shot.asyncCapture === 'function') shot.asyncCapture(params);
|
||||
else { reject(new Error('screenshot object exposes neither capture method')); return; }
|
||||
} catch (e) { reject(new Error('capture failed: ' + e.message)); return; }
|
||||
|
||||
// Poll for the file rather than trusting a return value: sync and async differ, and the
|
||||
// documented contract is "a file appears", not "a promise settles".
|
||||
var waited = 0;
|
||||
var tick = function () {
|
||||
var st = null;
|
||||
try { st = fs.statSync(path); } catch (e) { st = null; }
|
||||
if (st && st.size > 512) {
|
||||
var b64;
|
||||
try { b64 = fs.readFileSync(path).toString('base64'); }
|
||||
catch (e) { reject(new Error('could not read the capture: ' + e.message)); return; }
|
||||
try { fs.unlinkSync(path); } catch (e) { /* best-effort: never let cleanup fail a good capture */ }
|
||||
resolve('data:image/jpeg;base64,' + b64);
|
||||
return;
|
||||
}
|
||||
waited += 150;
|
||||
if (waited > (o.timeoutMs || 8000)) { reject(new Error('capture produced no file in ' + waited + 'ms')); return; }
|
||||
global.setTimeout(tick, 150);
|
||||
};
|
||||
global.setTimeout(tick, 150);
|
||||
});
|
||||
},
|
||||
|
||||
requestSnapshot: function (opts) {
|
||||
var o = opts || {};
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
|
@ -625,6 +834,30 @@
|
|||
|
||||
onHostMessage: function (fn) { if (typeof fn === 'function') listeners.push(fn); },
|
||||
|
||||
/*
|
||||
* Host diagnostics, routed into the channels the player already speaks.
|
||||
*
|
||||
* The host sees things the page has no API for — the uptime, the wired IP, the video mode
|
||||
* actually in force, which volume it booted from, whether a staged package applied — and until
|
||||
* now it printed all of it to a serial console. On a panel on a wall that is the same as not
|
||||
* reporting it. A bad string literal once stopped this script compiling and the only evidence
|
||||
* anywhere was on a cable; the server just saw a player that never appeared.
|
||||
*
|
||||
* These are deliberately thin: the bridge does not decide what a log line or an incident MEANS,
|
||||
* it just carries them to the player, which sends them the same way it sends its own.
|
||||
*/
|
||||
onHostLog: function (fn) {
|
||||
if (typeof fn !== 'function') return;
|
||||
logSinks.push(fn);
|
||||
drain(pendingLogs, fn);
|
||||
},
|
||||
|
||||
onHostEvent: function (fn) {
|
||||
if (typeof fn !== 'function') return;
|
||||
eventSinks.push(fn);
|
||||
drain(pendingEvents, fn);
|
||||
},
|
||||
|
||||
/*
|
||||
* Telemetry, read synchronously from a cache.
|
||||
*
|
||||
|
|
@ -658,6 +891,199 @@
|
|||
} catch (e) { /* older OS without the call */ }
|
||||
}
|
||||
|
||||
/*
|
||||
* The address this player holds on the LAN — the one an integrator needs to reach its DWS on
|
||||
* site, and the field the dashboard has always had a slot for and never been able to fill.
|
||||
*
|
||||
* This is Node's own `os.networkInterfaces()`, which is what BrightSign's dev-cookbook does in
|
||||
* both html5-app-template/src/info.ts and src-js/info.js. The widget is created with
|
||||
* nodejs_enabled, so the standard library is simply there — there is no @brightsign module for
|
||||
* this, and looking for one is a dead end that cost a whole afternoon:
|
||||
*
|
||||
* @brightsign/networkconfiguration EXISTS but exposes only callback,
|
||||
* getNeighborInformation and enableLeds — no config reader at all.
|
||||
* @brightsign/hostconfiguration has getConfig()/applyConfig(), but it returns HOST settings
|
||||
* (forwardingEnabled, hostName, loginPassword, nameServers…) with no address in them.
|
||||
*
|
||||
* Both verified by enumerating the live objects on our XT245 (FW 9.1.93.2), not from docs —
|
||||
* the docs pages for the JavaScript API 404, and their own roNetworkConfiguration page links
|
||||
* to one of the dead URLs. getCurrentConfig() is BrightScript-only.
|
||||
*
|
||||
* `internal` is Node's own loopback flag, which beats string-matching 127.*; the 169.254
|
||||
* link-local a player assigns itself when DHCP never answered is still filtered by hand,
|
||||
* because sending an operator to an unreachable address is worse than showing nothing.
|
||||
*
|
||||
* family is compared loosely: it is the string "IPv4" on the Node in this firmware (and in
|
||||
* the cookbook), but became the number 4 in Node 18, and this file outlives firmwares.
|
||||
*/
|
||||
if (osModule && typeof osModule.networkInterfaces === 'function') {
|
||||
try {
|
||||
var ifaces = osModule.networkInterfaces() || {};
|
||||
var names = Object.keys(ifaces);
|
||||
for (var ni = 0; ni < names.length; ni++) {
|
||||
var addrs = ifaces[names[ni]] || [];
|
||||
for (var ai = 0; ai < addrs.length; ai++) {
|
||||
var a = addrs[ai];
|
||||
if (!a || a.internal) continue;
|
||||
var ip = String(a.address || '');
|
||||
if (!ip) continue;
|
||||
var isV4 = (a.family === 'IPv4' || a.family === 4);
|
||||
var isV6 = (a.family === 'IPv6' || a.family === 6);
|
||||
if (isV4 && !telemetry.local_ip && ip.indexOf('169.254.') !== 0) telemetry.local_ip = ip;
|
||||
/*
|
||||
* The v6 column has existed as long as the v4 one and has never held anything, on any
|
||||
* player. The dashboard is already built for it — it renders a second card ONLY when
|
||||
* this is set, precisely so the overwhelmingly v4 fleet does not pay screen space for
|
||||
* an empty row.
|
||||
*
|
||||
* fe80:: is skipped for the same reason 169.254 is: a link-local address is scoped to
|
||||
* one interface and cannot be dialled from a laptop across the office, so reporting it
|
||||
* would send someone somewhere they cannot go. A ULA (fd00::/8) is kept — that IS
|
||||
* reachable on the site network, which is the question this field answers.
|
||||
*/
|
||||
if (isV6 && !telemetry.local_ip6 && ip.toLowerCase().indexOf('fe80') !== 0) {
|
||||
// Node appends a zone id to link-locals ("fe80::1%eth0"); strip any that survives.
|
||||
var pct = ip.indexOf('%');
|
||||
telemetry.local_ip6 = pct === -1 ? ip : ip.slice(0, pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* no networking yet, or a firmware without it — stay silent */ }
|
||||
}
|
||||
|
||||
/*
|
||||
* WHICH SCREEN IS PLUGGED IN, and what the output is actually driving.
|
||||
*
|
||||
* The first question about a dark sign is "which panel is that?", and until now the dashboard
|
||||
* could not answer it: screen_width/height are what the PAGE believes it has, which is the
|
||||
* widget's own geometry, not what the hardware negotiated with the display.
|
||||
*
|
||||
* The output is chosen by SCREEN NUMBER, because a dual-output player registers one device
|
||||
* row per output (?screen=N, see output_index) and each row must report its OWN panel — a box
|
||||
* driving a lobby TV and a menu board would otherwise show the lobby TV twice.
|
||||
*
|
||||
* Both names are tried. Probed on an XT245 (FW 9.1.93.2): "hdmi" and "HDMI-1" both resolve to
|
||||
* output 1 and answer with the same monitor, while a second output that does not exist fails
|
||||
* cleanly — "hdmi2" throws from the constructor and "HDMI-2" rejects. So a single-output
|
||||
* player simply reports nothing here rather than inventing a screen.
|
||||
*/
|
||||
if (VideoOutputClass) {
|
||||
var wantScreen = screenNumber();
|
||||
var outNames = ['HDMI-' + wantScreen];
|
||||
if (wantScreen === 1) outNames.push('hdmi');
|
||||
for (var oi = 0; oi < outNames.length; oi++) {
|
||||
try {
|
||||
var vo = new VideoOutputClass(outNames[oi]);
|
||||
if (!vo || typeof vo.getEdidIdentity !== 'function') continue;
|
||||
var edid = vo.getEdidIdentity();
|
||||
if (edid && typeof edid.then === 'function') {
|
||||
edid.then(function (e) {
|
||||
var mn = e && (e.monitorName || e.monitor_name);
|
||||
if (typeof mn === 'string' && mn.trim()) telemetry.attached_display = mn.trim();
|
||||
}, function () { /* no display on this output */ });
|
||||
}
|
||||
} catch (e) { /* no such output on this model */ }
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The mode the output is negotiated to, which is not the same as the widget's size. Reported
|
||||
* as WxH@Hz so it reads the way an installer would say it out loud. Our XT245 answers
|
||||
* 1920x1200@60 — the panel's native mode, while the page reports its own 1920x1080 canvas.
|
||||
*/
|
||||
if (VideoModeConfigClass) {
|
||||
try {
|
||||
var vmc = new VideoModeConfigClass();
|
||||
if (vmc && typeof vmc.getActiveMode === 'function') {
|
||||
var mode = vmc.getActiveMode();
|
||||
if (mode && typeof mode.then === 'function') {
|
||||
mode.then(function (m) {
|
||||
if (!m) return;
|
||||
var w = m.graphicsPlaneWidth || m.width;
|
||||
var h = m.graphicsPlaneHeight || m.height;
|
||||
var f = m.frequency || m.refreshRate;
|
||||
if (w && h) telemetry.video_mode = w + 'x' + h + (f ? '@' + f : '');
|
||||
}, function () { /* mode not readable on this firmware */ });
|
||||
}
|
||||
}
|
||||
} catch (e) { /* older OS without the call */ }
|
||||
}
|
||||
|
||||
/*
|
||||
* Memory, load and REAL uptime — all from the same Node standard library the address above
|
||||
* came from, and all previously NULL on every BrightSign in the fleet.
|
||||
*
|
||||
* uptime deliberately OVERRIDES the page's own figure. index.html sends
|
||||
* performance.now()/1000, which is how long this PAGE has been up; a widget rebuilt by the
|
||||
* watchdog resets it while the player has been running for weeks. os.uptime() is the machine,
|
||||
* which is what an operator reading "uptime" means and what makes a reboot loop visible.
|
||||
*
|
||||
* cpu_usage is the 1-minute load average normalised by core count and expressed as a
|
||||
* percentage, so it is comparable with what the other players report rather than being a raw
|
||||
* load figure that means nothing next to them. Clamped, because load can exceed core count.
|
||||
*/
|
||||
if (osModule) {
|
||||
try {
|
||||
if (typeof osModule.totalmem === 'function' && typeof osModule.freemem === 'function') {
|
||||
var totalB = osModule.totalmem();
|
||||
var freeB = osModule.freemem();
|
||||
if (isFinite(totalB) && totalB > 0) telemetry.ram_total_mb = Math.round(totalB / 1048576);
|
||||
if (isFinite(freeB) && freeB >= 0) telemetry.ram_free_mb = Math.round(freeB / 1048576);
|
||||
}
|
||||
if (typeof osModule.uptime === 'function') {
|
||||
var up = osModule.uptime();
|
||||
if (isFinite(up) && up > 0) telemetry.uptime_seconds = Math.round(up);
|
||||
}
|
||||
if (typeof osModule.loadavg === 'function' && typeof osModule.cpus === 'function') {
|
||||
var la = osModule.loadavg();
|
||||
var cores = (osModule.cpus() || []).length || 1;
|
||||
if (la && isFinite(la[0])) {
|
||||
var pct = Math.round((la[0] / cores) * 100);
|
||||
telemetry.cpu_usage = pct < 0 ? 0 : (pct > 100 ? 100 : pct);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* a firmware without part of the stdlib — report what did work */ }
|
||||
}
|
||||
|
||||
/*
|
||||
* REAL disk, from statfs rather than the browser's storage quota.
|
||||
*
|
||||
* The quota is what this file used to report and it is not the disk: our XT245 answered
|
||||
* "1026 MB total" for a 119 GB NVMe, because navigator.storage.estimate() describes the
|
||||
* widget's cache budget. An operator reading that has been told something false about the
|
||||
* machine, which is worse than an empty field.
|
||||
*
|
||||
* The volume is DISCOVERED, not assumed. BrightSign mounts storage under /storage (SD, SSD,
|
||||
* USB), and which one a given player boots from varies — ours runs from an NVMe while the
|
||||
* card slot is dead. So statfs every mount and keep the largest, which is the content volume
|
||||
* on every shape of player. Falls back to the widget's own working directory.
|
||||
*/
|
||||
if (fsModule && typeof fsModule.statfsSync === 'function') {
|
||||
try {
|
||||
var candidates = [];
|
||||
try {
|
||||
var mounts = fsModule.readdirSync('/storage') || [];
|
||||
for (var mi = 0; mi < mounts.length; mi++) candidates.push('/storage/' + mounts[mi]);
|
||||
} catch (e) { /* no /storage on this firmware */ }
|
||||
candidates.push('/');
|
||||
var bestTotal = 0, bestFree = 0;
|
||||
for (var ci = 0; ci < candidates.length; ci++) {
|
||||
try {
|
||||
var st = fsModule.statfsSync(candidates[ci]);
|
||||
if (!st || !isFinite(st.blocks) || !isFinite(st.bsize)) continue;
|
||||
var tot = st.blocks * st.bsize;
|
||||
// bavail is space usable by an unprivileged writer; bfree includes the reserve.
|
||||
var fre = (isFinite(st.bavail) ? st.bavail : st.bfree) * st.bsize;
|
||||
if (tot > bestTotal) { bestTotal = tot; bestFree = fre; }
|
||||
} catch (e) { /* not a mount point */ }
|
||||
}
|
||||
if (bestTotal > 0) {
|
||||
telemetry.storage_total_mb = Math.round(bestTotal / 1048576);
|
||||
telemetry.storage_free_mb = Math.round(bestFree / 1048576);
|
||||
}
|
||||
} catch (e) { /* leave the quota estimate below to fill in */ }
|
||||
}
|
||||
|
||||
/*
|
||||
* REAL device storage, when the host could see a volume.
|
||||
*
|
||||
|
|
|
|||
103
docs/licensing.md
Normal file
103
docs/licensing.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# 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
|
||||
info:
|
||||
title: ScreenTinker Public API
|
||||
version: 1.9.29
|
||||
version: 1.9.36
|
||||
description: |
|
||||
Public, token-scoped REST API for ScreenTinker digital signage.
|
||||
|
||||
|
|
@ -122,9 +122,19 @@ components:
|
|||
local_ip:
|
||||
type: [string, "null"]
|
||||
description: |
|
||||
The device's **own address on its local network** (e.g. `192.168.1.42`), as
|
||||
The device's **own IPv4 address on its local network** (e.g. `192.168.1.42`), as
|
||||
reported by the player itself. This is the one to use to reach a panel directly on
|
||||
site. Null on players that do not report it, or where the platform withholds it.
|
||||
site. Null on players that do not report it, where the platform withholds it, or on
|
||||
a panel with no IPv4 address at all — see `local_ip6`.
|
||||
local_ip6:
|
||||
type: [string, "null"]
|
||||
description: |
|
||||
The device's **own IPv6 address on its local network**, reported alongside
|
||||
`local_ip` rather than instead of it: a dual-stack panel has both and either may be
|
||||
the one you need. Link-local addresses (`fe80::/10`) are deliberately excluded —
|
||||
every interface has one and none can be reached without also knowing the zone
|
||||
index, so this carries a global or unique-local address or nothing. Null on players
|
||||
that do not report it and on IPv4-only panels.
|
||||
|
||||
# --- Latest telemetry --------------------------------------------------------------
|
||||
# Flattened from the most recent telemetry report. All null for a device that has
|
||||
|
|
@ -673,7 +683,11 @@ paths:
|
|||
widget_id: { type: string }
|
||||
zone_id: { type: string }
|
||||
sort_order: { type: integer }
|
||||
duration_sec: { type: integer }
|
||||
duration_sec:
|
||||
type: integer
|
||||
description: >
|
||||
Omit to let the server choose: video content defaults to the clip's own
|
||||
length (rounded up to a whole second), anything else to 10s.
|
||||
responses:
|
||||
'201': { description: Created playlist item. }
|
||||
/playlists/{id}/items/reorder:
|
||||
|
|
@ -815,7 +829,11 @@ paths:
|
|||
content_id: { type: string }
|
||||
widget_id: { type: string }
|
||||
zone_id: { type: string }
|
||||
duration_sec: { type: integer }
|
||||
duration_sec:
|
||||
type: integer
|
||||
description: >
|
||||
Omit to let the server choose: video content defaults to the clip's own
|
||||
length (rounded up to a whole second), anything else to 10s.
|
||||
sort_order: { type: integer }
|
||||
responses:
|
||||
'201': { description: Created item. }
|
||||
|
|
@ -1242,7 +1260,11 @@ paths:
|
|||
required: [content_id]
|
||||
properties:
|
||||
content_id: { type: string }
|
||||
duration_sec: { type: integer }
|
||||
duration_sec:
|
||||
type: integer
|
||||
description: >
|
||||
Omit to let the server choose: video content defaults to the clip's own
|
||||
length (rounded up to a whole second), anything else to 10s.
|
||||
responses:
|
||||
'200': { description: '{ success, devices_updated }.' }
|
||||
/groups/{id}/assign-playlist:
|
||||
|
|
@ -1560,7 +1582,18 @@ paths:
|
|||
device_id: { type: string }
|
||||
grid_col: { type: integer }
|
||||
grid_row: { type: integer }
|
||||
rotation: { type: integer }
|
||||
rotation:
|
||||
type: integer
|
||||
enum: [0, 90, 180, 270]
|
||||
default: 0
|
||||
description: >-
|
||||
How this panel is physically mounted, as degrees CLOCKWISE that its image
|
||||
must be turned to come out upright on the wall (the same convention as a
|
||||
device's `orientation`). canvas_* are in WALL space — the wall as the
|
||||
audience sees it — so a portrait-mounted 1920x1080 panel is a tall tile
|
||||
with rotation 90, and content needs no pre-rotating. Anything other than
|
||||
0/90/180/270 is stored as 0. While a panel is in a wall this replaces its
|
||||
own `orientation`, so the two can never rotate the content twice.
|
||||
canvas_x: { type: number }
|
||||
canvas_y: { type: number }
|
||||
canvas_width: { type: number }
|
||||
|
|
|
|||
356
docs/operations.md
Normal file
356
docs/operations.md
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
# Operations runbook
|
||||
|
||||
Running an instance day to day: deploying, verifying, rolling back, and the traps that have actually
|
||||
cost people time.
|
||||
|
||||
The README covers the happy paths — [installing](../README.md#production-deployment),
|
||||
[updating](../README.md#updating), [backups](../README.md#backups) and
|
||||
[admin recovery](../README.md#admin-recovery). This is the part you want at 2am, or when a deploy
|
||||
did not behave.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Two deployment shapes](#two-deployment-shapes)
|
||||
- [Before you deploy](#before-you-deploy)
|
||||
- [Deploying: native (git + systemd)](#deploying-native-git--systemd)
|
||||
- [Deploying: Docker](#deploying-docker)
|
||||
- [The served APK](#the-served-apk)
|
||||
- [Verifying a deploy](#verifying-a-deploy)
|
||||
- [Rolling back](#rolling-back)
|
||||
- [Releases and version numbers](#releases-and-version-numbers)
|
||||
- [Upgrading Node.js](#upgrading-nodejs)
|
||||
- [Traps worth knowing before they bite](#traps-worth-knowing-before-they-bite)
|
||||
|
||||
---
|
||||
|
||||
## Two deployment shapes
|
||||
|
||||
An instance is either **native** (a git checkout on a release tag, run by systemd) or **Docker** (a
|
||||
published image, run by compose). They are not interchangeable, and the commands differ at every
|
||||
step.
|
||||
|
||||
> ⚠️ **Know which one you are on before you type anything.** The most expensive mistakes in this
|
||||
> runbook come from applying one shape's procedure to the other — `git checkout` on a Docker host
|
||||
> changes nothing the container is running, and bumping an image tag on a native host does nothing
|
||||
> at all. If you run more than one instance, keep a note of which is which somewhere you will read.
|
||||
|
||||
---
|
||||
|
||||
## Before you deploy
|
||||
|
||||
Every time, in this order:
|
||||
|
||||
1. **Snapshot the database.**
|
||||
```bash
|
||||
sqlite3 <db> ".backup /path/to/pre-<version>-$(date +%Y%m%d-%H%M%S).db"
|
||||
sqlite3 /path/to/pre-<version>-*.db "PRAGMA integrity_check;" # want: ok
|
||||
```
|
||||
2. **Record the row counts** you intend to still have afterwards:
|
||||
```sql
|
||||
SELECT (SELECT COUNT(*) FROM devices), (SELECT COUNT(*) FROM users),
|
||||
(SELECT COUNT(*) FROM content), (SELECT COUNT(*) FROM playlists);
|
||||
```
|
||||
3. **Check whether dependencies changed.** If `server/package.json` differs by more than the version
|
||||
field between the running release and the target, you need an install step. If it differs only in
|
||||
`"version"`, skip it — that is the cheapest and safest kind of deploy.
|
||||
```bash
|
||||
git diff <current-tag> <target-tag> -- server/package.json
|
||||
```
|
||||
4. **Check whether migrations will run.** They apply automatically at boot. Additive columns and new
|
||||
tables are safe, and a code-only rollback simply leaves them unused.
|
||||
```bash
|
||||
git diff <current-tag> <target-tag> -- server/db/database.js | grep -E '^\+.*(ALTER|CREATE) TABLE|CREATE INDEX'
|
||||
```
|
||||
5. **Back up the compose file / the served APK** if you are about to change either.
|
||||
|
||||
---
|
||||
|
||||
## Deploying: native (git + systemd)
|
||||
|
||||
`scripts/upgrade.sh` does the whole sequence — snapshot, checkout, `npm ci --omit=dev`, restart, and
|
||||
report the running version. It defaults to the newest **stable** tag, deliberately skipping
|
||||
`-rc`/`-beta`/`-alpha` prereleases:
|
||||
|
||||
```bash
|
||||
cd /opt/screentinker
|
||||
scripts/upgrade.sh # latest stable release
|
||||
scripts/upgrade.sh v1.2.3 # or pin one
|
||||
```
|
||||
|
||||
If you are doing it by hand, the order matters:
|
||||
|
||||
```bash
|
||||
sudo -u <service-user> git fetch --tags origin
|
||||
sudo -u <service-user> git checkout -f v1.2.3
|
||||
# only if dependencies actually changed:
|
||||
cd server && sudo -u <service-user> npm ci --omit=dev
|
||||
sudo systemctl restart <service>
|
||||
```
|
||||
|
||||
**Ownership first.** Every file must belong to the service user *before* the checkout. A checkout
|
||||
that fails partway through leaves the worst possible state: `VERSION` updated while the code is
|
||||
still the old release, so the service reports a version it is not running and no migrations ran.
|
||||
|
||||
```bash
|
||||
sudo chown -R <service-user>:<service-user> /opt/screentinker
|
||||
```
|
||||
|
||||
**A service user with no home directory breaks npm.** It writes logs and a cache to `$HOME`, which
|
||||
does not exist, and installs nothing while looking like it worked:
|
||||
|
||||
```bash
|
||||
cd server && sudo -u <service-user> env HOME=/opt/screentinker \
|
||||
npm_config_cache=/opt/screentinker/.npm-cache npm ci --omit=dev
|
||||
```
|
||||
|
||||
**Prove the checkout is complete** — a version string alone will not tell you:
|
||||
|
||||
```bash
|
||||
git status --porcelain --untracked-files=no # want: empty
|
||||
git diff <tag> -- server frontend # want: empty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploying: Docker
|
||||
|
||||
```bash
|
||||
# in the compose directory
|
||||
cp -a docker-compose.yml docker-compose.yml.bak-pre-<version>
|
||||
sed -i 's|screentinker:<old>|screentinker:<new>|' docker-compose.yml
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Migrations run at boot exactly as they do natively. State lives in the named volume (`st-data` in
|
||||
the example compose), so recreating the container does not touch the database.
|
||||
|
||||
Anything bind-mounted into the container — the served APK, a `.wgt`, custom assets — must be updated
|
||||
on the **host**, and see the inode warning below.
|
||||
|
||||
---
|
||||
|
||||
## The served APK
|
||||
|
||||
The file the OTA endpoint hands to Android displays. Two rules, both learned the hard way.
|
||||
|
||||
**1. Replace it in place. Never `mv` or `cp` over it.**
|
||||
|
||||
It is a bind-mounted *file*, so the container holds the inode. Replacing the file gives the host a
|
||||
new inode and the container keeps serving the old bytes forever, with nothing in any log to say so.
|
||||
|
||||
```bash
|
||||
cat /tmp/new.apk > /opt/screentinker/ScreenTinker.apk # correct — same inode
|
||||
# NOT: mv, cp, install, or anything that unlinks and recreates
|
||||
stat -c %i /opt/screentinker/ScreenTinker.apk # confirm it did not change
|
||||
```
|
||||
|
||||
**2. The advertised size must match the served bytes, or displays loop.**
|
||||
|
||||
`/api/update/check` reports `apk_size` from a cache refreshed every `OTA_APK_REFRESH_MS`
|
||||
(default 60s), and the server re-stats at boot. If the advertised size and the real file disagree,
|
||||
a display downloads, rejects, and retries — forever. After swapping, restart the service and confirm:
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:3001/api/update/check?version=<an-older-version>'
|
||||
stat -c %s /opt/screentinker/ScreenTinker.apk # must equal the reported apk_size
|
||||
```
|
||||
|
||||
> ⚠️ The query parameter is **`version`**, not `current_version`. The wrong name yields
|
||||
> `reason: no-version, update_available: false`, which looks exactly like a broken OTA but is not.
|
||||
> `/api/version` is a different endpoint and its `update_available` is not the OTA verdict.
|
||||
|
||||
**Verify the signature after any APK swap**, and use `jarsigner`:
|
||||
|
||||
```bash
|
||||
jarsigner -verify ScreenTinker.apk # want: "jar verified."
|
||||
unzip -l ScreenTinker.apk | grep META-INF # want: a .SF and a .RSA
|
||||
```
|
||||
|
||||
`apksigner verify -v` misreports `v1 scheme: false` on some build-tools versions even when the JAR
|
||||
signature is present and valid. MDM-managed signage needs v1, so trust `jarsigner`.
|
||||
|
||||
---
|
||||
|
||||
## Verifying a deploy
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3001/api/version # version + build hash
|
||||
curl -s http://127.0.0.1:3001/api/status # health, loop lag, connected displays
|
||||
```
|
||||
|
||||
Then, and this is the part people skip:
|
||||
|
||||
- **Row counts match** what you recorded beforehand.
|
||||
- **The log is clean.** Migrations reported, no errors:
|
||||
```bash
|
||||
docker logs <container> 2>&1 | grep -iE 'migrat|error|exception' # or journalctl -u <service>
|
||||
```
|
||||
- **Check through your reverse proxy / CDN too**, not only on loopback. Cached or misrouted assets
|
||||
only show up from outside.
|
||||
|
||||
> ⚠️ **A version string is not proof the new code is running, and neither is the build hash.** The
|
||||
> hash covers the frontend, so a server-only change deploys with an *unchanged* hash — which looks
|
||||
> exactly like a stale image. When it matters, check for the code itself:
|
||||
> ```bash
|
||||
> docker exec <container> grep -c '<a symbol only the new version has>' /app/server/<file>
|
||||
> ```
|
||||
|
||||
**A frontend change needs a hard refresh** (Ctrl+Shift+R) before you judge it. Assets revalidate,
|
||||
but a browser sitting on the old bundle will show you the old behaviour and you will debug a fixed
|
||||
bug.
|
||||
|
||||
---
|
||||
|
||||
## Rolling back
|
||||
|
||||
Because backups are taken per deploy, rollback is mechanical:
|
||||
|
||||
**Native**
|
||||
```bash
|
||||
sudo -u <service-user> git checkout -f <previous-tag>
|
||||
cd server && npm ci --omit=dev # only if dependencies changed
|
||||
cat /path/to/ScreenTinker.apk.bak > /opt/screentinker/ScreenTinker.apk
|
||||
sudo systemctl restart <service>
|
||||
```
|
||||
|
||||
**Docker**
|
||||
```bash
|
||||
cp -a docker-compose.yml.bak-<version> docker-compose.yml
|
||||
cat /path/to/ScreenTinker.apk.bak > /opt/screentinker/ScreenTinker.apk
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**The database usually does not need restoring.** Migrations are additive, so older code simply
|
||||
ignores the new columns. Restore the snapshot only if a migration was destructive — and if one ever
|
||||
is, that is the moment to stop and read it rather than reflexively rolling forward.
|
||||
|
||||
---
|
||||
|
||||
## Releases and version numbers
|
||||
|
||||
Cutting a release is documented in [RELEASING.md](../RELEASING.md). The operational consequences:
|
||||
|
||||
**A prerelease sorts BELOW its own release.** `1.2.3-alpha1` is semver-older than `1.2.3`. That has
|
||||
two effects worth internalising:
|
||||
|
||||
- A display that takes a prerelease is not "ahead"; a later stable of the same version supersedes it,
|
||||
which is what you want.
|
||||
- The Android update check offers a prerelease to any older client on the **stable** channel. Putting
|
||||
a prerelease on an instance means every Android display below it takes it at its next check. Do
|
||||
that deliberately, on an instance whose displays you are willing to move.
|
||||
|
||||
**`:latest` is not moved for a prerelease.** The release workflow skips it for any tag containing a
|
||||
`-`, so nobody tracking `:latest` pulls untested code on their next restart.
|
||||
|
||||
**Android `versionCode` must never go backwards.** Android refuses a downgrade, so a build with a
|
||||
lower code cannot install over a higher one — the usual cause is a side-loaded test build whose code
|
||||
was bumped past the release line. Keep the release line ahead of anything you side-load, or you will
|
||||
be reinstalling by hand (which wipes app data and drops pairing).
|
||||
|
||||
**A re-cut tag is only safe if it published nothing.** If a tag has already produced a GitHub Release
|
||||
or an image, delete-and-repush is not a fix; cut the next version instead.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading Node.js
|
||||
|
||||
Upgrading the runtime is not like deploying a release: nothing in the app's own upgrade path is
|
||||
involved, so the usual `scripts/upgrade.sh` never runs and nothing reinstalls dependencies. Read
|
||||
this before changing the Node major.
|
||||
|
||||
**Do it as two separate deploys, never one.** Move the app to a release whose dependencies support
|
||||
both the old and new Node major first, confirm it on the runtime you already have, and only then
|
||||
change Node. Each half is then independently reversible. Doing both at once means a failure gives
|
||||
you nothing to bisect and no single step to undo.
|
||||
|
||||
**Check the version floor.** `npm start` uses `node --env-file-if-exists=.env`. That flag reached
|
||||
the Node 22 line only in **22.9.0** — it works on Node 20 because it was separately backported
|
||||
there. On Node 22.0–22.8 the server refuses to start with `node: bad option`. Target 22.9.0 or
|
||||
newer.
|
||||
|
||||
**One native module has to survive the move.** `better-sqlite3` is compiled against a single Node
|
||||
ABI, so changing Node invalidates it. Two things make this survivable:
|
||||
|
||||
- `lib/preflight-deps.js` runs before anything else at boot, detects the mismatch by *opening a
|
||||
database* (a bare `require` succeeds even on a wrong ABI, so it is not a valid check), and repairs
|
||||
it with `npm rebuild better-sqlite3`.
|
||||
- The pinned version ships **prebuilt binaries for both the current and the next Node major**, so
|
||||
that repair downloads a binary instead of compiling one.
|
||||
|
||||
⚠️ **That second point is why the version is pinned exactly rather than with a caret**, and why
|
||||
widening it is risky in a way `package.json` does not show. A version with no prebuild for your Node
|
||||
falls back to a from-source `node-gyp` build — and because preflight rebuilds *synchronously before
|
||||
the server listens*, a compile that outlives `TimeoutStartSec` turns `Restart=always` into a boot
|
||||
loop that never finishes. Before changing that pin, check the project's release assets and confirm a
|
||||
prebuild exists for every Node ABI you intend to run. A build toolchain (`python3`, `make`, `g++`)
|
||||
should still be present as a fallback.
|
||||
|
||||
**Native (git + systemd)**
|
||||
|
||||
1. Back up first — a Node upgrade cannot corrupt the database, but you want the rollback anyway.
|
||||
2. Change the Node major. If Node came from a distribution repository pinned to a major, the repo
|
||||
definition itself must be repointed — upgrading the package alone can never cross majors, and
|
||||
this pin lives in system configuration rather than in this repository.
|
||||
3. `node --version` to confirm.
|
||||
4. Rebuild the native module explicitly (`npm rebuild better-sqlite3` as the service user, in
|
||||
`server/`), or let preflight do it on the next restart. Doing it by hand keeps the logs readable.
|
||||
5. Restart, then verify as in [Verifying a deploy](#verifying-a-deploy). In the logs, confirm
|
||||
preflight reports a successful rebuild rather than exiting.
|
||||
|
||||
**Docker** — nothing to rebuild. Change the base image, build, and deploy the new tag: dependencies
|
||||
are installed inside the image against its own Node, so the ABI can never be stale. Rollback is
|
||||
repinning the previous tag.
|
||||
|
||||
**Afterwards, move CI too.** CI pins its own Node version, and it will happily keep validating a
|
||||
version nobody runs — which is worse than no signal, because it looks like coverage. The Docker base
|
||||
image is a separate pin from the CI one; both need changing or what CI tests and what ships diverge.
|
||||
|
||||
---
|
||||
|
||||
## Traps worth knowing before they bite
|
||||
|
||||
**Native modules are built for one Node ABI.** `better-sqlite3` is compiled against the Node that
|
||||
installed it. Run the app — or its tests — under a different major version and it fails with
|
||||
`NODE_MODULE_VERSION` mismatch, which presents as hundreds of unrelated test failures rather than
|
||||
one clear error. Use the same Node the service runs. See [Upgrading Node.js](#upgrading-nodejs)
|
||||
before changing it deliberately.
|
||||
|
||||
**SQLite foreign keys are off unless enabled per connection.** A declared `ON DELETE CASCADE` does
|
||||
not fire on its own, so deleting a parent row can leave orphaned children. Check with
|
||||
`PRAGMA foreign_key_check;` after any bulk delete.
|
||||
|
||||
**Deploying reloads every connected web player.** The frontend self-reloads when the build hash
|
||||
changes. Browsers cope. Some embedded webview players do not, and may need a restart afterwards —
|
||||
worth knowing before you deploy during business hours.
|
||||
|
||||
**An SSO-linked administrator has no password.** If you link the platform administrator account to
|
||||
an identity provider and that provider later fails, the login page cannot help you. Recovery is
|
||||
`node scripts/reset-admin.js` on the server. See [sso-setup.md](sso-setup.md).
|
||||
|
||||
**Backups are only real once restored.** A snapshot that has never been restored is a hypothesis.
|
||||
Periodically restore the newest one into a throwaway instance and confirm it boots and serves
|
||||
`/api/status`.
|
||||
|
||||
**`curl … | sudo bash` answers the installer's questions for you.** The pipe *is* stdin, and bash
|
||||
has consumed it before any prompt runs — so every question gets an instant end-of-input and the
|
||||
script takes the default. On the Pi installer that meant the mode menu appeared to skip itself and
|
||||
Player-Only was unreachable through the documented command. Fixed there (prompts read the terminal
|
||||
now), but the trap is general: any piped installer that asks you something is not really asking.
|
||||
Download the script and run it, or pass the answers as flags.
|
||||
|
||||
**A Raspberry Pi 5 on Bookworm runs Wayland, and X11 tools fail silently there.** `xset`,
|
||||
`unclutter` and `xrandr` return an error and do nothing — so screen blanking is never suppressed and
|
||||
the cursor is never hidden, while every command in your setup notes appears to have worked. If you
|
||||
have hand-rolled kiosk tweaks on a Pi, check which session is actually running (`echo
|
||||
$XDG_SESSION_TYPE`) before trusting them. The bundled launcher detects this and uses `wlopm` plus
|
||||
`--ozone-platform=wayland` on Wayland.
|
||||
|
||||
**Overlay FS protects the SD card and discards everything written to it.** Reasonable on a
|
||||
**player-only** Pi, where the loss is a content cache that simply re-downloads after each boot. Not
|
||||
usable for an **all-in-one** install as-is: the server writes continuously — the SQLite database,
|
||||
WAL, uploads and thumbnails — and a read-only root throws all of it away at reboot, so the instance
|
||||
silently reverts to its state at the moment you enabled overlay. If you want both, put `DATA_DIR` on
|
||||
a writable partition that overlay does not cover, and confirm a screen you add survives a power cut
|
||||
before relying on it.
|
||||
|
|
@ -7,115 +7,343 @@ it puts a control on the dashboard that cannot work.
|
|||
Capability names come from `server/lib/player-capabilities.js`. Players declare their own set at
|
||||
registration; a player that declares nothing falls back to the per-platform baseline in that file.
|
||||
|
||||
**Legend** — ✅ supported · ⚠️ partial/conditional (reason given) · ❌ not supported (reason given)
|
||||
**Legend** — ✅ verified in source · ⚠️ partial/conditional (reason given) · ❌ not supported (reason
|
||||
given) · 💀 **dead**: the capability is declared or baselined but the control cannot work · ❓
|
||||
**unverifiable from source** — needs hardware, and is marked as such rather than asserted.
|
||||
|
||||
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.
|
||||
`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) and the
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Read this first: three dead controls found by this audit
|
||||
|
||||
These are not gaps in coverage. They are controls a customer can press today that do nothing.
|
||||
|
||||
### 1. The volume slider works on Android only
|
||||
|
||||
`frontend/js/views/device-detail.js` sends `set_volume` as **`{ level: 0..1 }`**:
|
||||
|
||||
```js
|
||||
el?.addEventListener('change', () => sendCommand(device.id, cmd, { level: parseInt(el.value, 10) / 100 }));
|
||||
```
|
||||
|
||||
| player | what the handler reads | result |
|
||||
|---|---|---|
|
||||
| Android | `payload.optDouble("level", -1.0)` | ✅ works |
|
||||
| web | `payload.level` (fraction), `value` still read as a percentage | ✅ **fixed in 1.9.31** |
|
||||
| Tizen | `payload.level` (fraction) | ✅ fixed in 1.9.31 — but see the note below on when the baseline may move |
|
||||
| BrightSign | (the web player) | ✅ as web |
|
||||
|
||||
Three of the four players had a complete, working volume implementation that could not be driven,
|
||||
because nobody checked the payload key against the sender. Fixed in 1.9.31: the fraction is now
|
||||
canonical everywhere, and the scale is chosen by WHICH KEY arrived rather than by the magnitude of
|
||||
the number (`1` is legal under both conventions, so guessing from the value is wrong for somebody).
|
||||
|
||||
`audio.volume` is back in the `web` and `brightsign` baselines as of 1.9.31, and **not** in the
|
||||
`tizen` one. That asymmetry is the model, not an oversight — see
|
||||
[When a baseline may move](#when-a-baseline-may-move).
|
||||
|
||||
### 2. Every #161 Tier-2 command was refused for the entire fleet — FIXED here
|
||||
|
||||
`lock_now`, `power_menu`, `status_bar`, `block_uninstall` and `unblock_uninstall` were gated on
|
||||
`system.device_owner`. **No player declares that name** — not `PlayerCapabilities.kt`, not
|
||||
`tizen/js/capabilities.js`, not `declaredCapabilities()`, not `st-bridge.js` — and no baseline
|
||||
granted it. So `supports()` returned false for every device on every platform and all five commands
|
||||
were refused, *including on the device-owner panels the whole feature was built for*. The dashboard
|
||||
still drew the buttons, because `device-detail.js` gates that block on `device.tier === 2 ||` too,
|
||||
so an operator on a real owner panel pressed "Lock now" and got a silent server-side refusal.
|
||||
|
||||
Fixed in `player-capabilities.js`: those five now accept `system.device_owner` **or**
|
||||
`system.kiosk`. That is an exact stand-in, not a loose one — `PlayerCapabilities.kt` declares
|
||||
`system.kiosk` under `if (isOwner)` and nothing else, which is precisely when `STPolicy`'s `owned()`
|
||||
actions do anything, and no non-Android player declares it.
|
||||
|
||||
**Follow-up owned by Android:** `PlayerCapabilities.kt` should declare `system.device_owner` under
|
||||
`if (isOwner)`, at which point the stand-in becomes redundant.
|
||||
|
||||
### 3. The capture bootstrap required the capability it creates — half FIXED here
|
||||
|
||||
`enable_system_capture` raises Android's MediaProjection consent dialog: it is how a panel *gains*
|
||||
full-screen capture. It was gated on `remote.screenshot`, so the only panel that needs it — no
|
||||
accessibility, no projection grant, therefore no declared `remote.screenshot` — was the one panel
|
||||
that could not be sent it. The command is now ungated.
|
||||
|
||||
**Still broken, and it is a frontend change:** `device-detail.js` renders the button behind
|
||||
`can('remote.screenshot')`, so it is still hidden on exactly those panels.
|
||||
|
||||
---
|
||||
|
||||
## Playback
|
||||
|
||||
No command routes to any `playback.*` capability and no dashboard control is gated on one, so these
|
||||
describe content rendering. They are informational, and shown to the operator in the Info tab.
|
||||
|
||||
| capability | Android | Web | Tizen | BrightSign |
|
||||
|---|---|---|---|---|
|
||||
| `playback.video` | ✅ ExoPlayer | ✅ `<video>` | ✅ AVPlay | ✅ hardware plane |
|
||||
| `playback.image` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `playback.video` | ✅ ExoPlayer (`MediaPlayerManager`) | ✅ `<video>` | ✅ AVPlay | ✅ hardware plane |
|
||||
| `playback.image` | ✅ `ImageLoader` | ✅ | ✅ | ✅ |
|
||||
| `playback.widget` | ✅ WebView | ✅ iframe | ✅ iframe | ✅ iframe |
|
||||
| `playback.youtube` | ✅ WebView embed | ✅ IFrame API | ✅ iframe embed | ✅ IFrame API |
|
||||
| `playback.zones` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `playback.transitions` | ✅ GL wipes (#204) | ⚠️ declared only when the bundle loads — a failed load hard-cuts rather than breaking playback | ✅ | ⚠️ as web |
|
||||
| `playback.pip` | ✅ `PipOverlay` | ✅ `#pipContainer` | ✅ | ✅ |
|
||||
| `playback.zones` | ✅ `ZoneManager` | ✅ | ✅ | ✅ |
|
||||
| `playback.transitions` | ✅ `TransitionCompositor` | ⚠️ declared only when the bundle loads (`transitionRuntimeReady()`) — a failed load hard-cuts rather than breaking playback | ✅ `transitions.js` | ⚠️ composites DOM over video; with hwz it may be **invisible over video** and degrade to a hard cut |
|
||||
| `playback.pip` | ✅ `PipOverlay` | ✅ `#pipContainer` | ✅ `pip-overlay.js` | ⚠️ same hwz caveat as transitions |
|
||||
|
||||
## Audio
|
||||
|
||||
| capability | Android | Web | Tizen | BrightSign |
|
||||
|---|---|---|---|---|
|
||||
| `audio.mute` | ✅ incl. YouTube via IFrame bridge | ✅ | ✅ incl. YouTube via `postMessage` | ✅ as web |
|
||||
| `audio.volume` | ✅ `set_volume` | ✅ `set_volume` | ❌ **no `set_volume` handler exists** — the dashboard slider does nothing today | ✅ as web |
|
||||
| `audio.mute` | ✅ `device:mute-changed` → `setVideoMuted`, incl. YouTube via the IFrame bridge | ✅ | ✅ incl. YouTube via `postMessage` | ✅ as web |
|
||||
| `audio.volume` | ✅ `set_volume` reads `payload.level` | ✅ reads `payload.level` (1.9.31) | ✅ `applyVolume` reads `payload.level` (1.9.31, incl. `tizen.tvaudiocontrol`) | ✅ as web |
|
||||
|
||||
## Display
|
||||
|
||||
| capability | Android | Web | Tizen | BrightSign |
|
||||
|---|---|---|---|---|
|
||||
| `display.rotation` | ✅ native `rootView.rotation` | ✅ CSS transform | ✅ CSS + AVPlay for video | ⚠️ host rotates the output via `roVideoMode`; CSS alone cannot turn the hardware video plane |
|
||||
| `display.power` | ✅ `screen_off` / `lock_now` | ❌ a browser tab cannot power a panel — the overlay only paints black | ❌ `screen_off` draws a black overlay, deliberately, "so the command still does something visible" | ⚠️ media teardown always works; CEC is best-effort and absent on some units |
|
||||
| `display.resolution` | ❌ no video-mode control in the app | ❌ not addressable from a browser | ❌ | ✅ `roVideoMode` via the host |
|
||||
| `display.rotation` | ✅ native `rootView.rotation` — the ExoPlayer surface rotates with it | ✅ CSS transform | ✅ CSS + AVPlay `setDisplayRotation` for video | ⚠️ CSS cannot turn the hardware video plane; the host would have to (`roVideoMode`), and the page never calls `BS.setVideoMode` |
|
||||
| `display.power` | ⚠️ conditional. `screen_off` needs owner / device-admin FORCE_LOCK / accessibility; `screen_on` is a **wake lock**, which works anywhere — but only since `812e89f`. On the fielded build `screen_on` is a logged no-op, which is why the Android baseline no longer claims this | ❌ a browser tab cannot power a panel — the overlay only paints black | ✅ both halves on every build, no signing needed: `showScreenOff()` / `clearScreenOff()`, plus the real panel API where `STDeviceControl` finds one | ⚠️ needs `hasHost()`. Media teardown always blanks; ❓ **CEC is unverified** — our XT245 resolves `@brightsign/cec` while the kernel logs `failed to get cec clock` and the display never responds |
|
||||
| `display.resolution` | ❌ needs system/root | ❌ not addressable from a browser | ❌ no web-accessible mode setting on the TV profile | ⚠️ **declared but unreachable** — `st-bridge.js` exposes `setVideoMode`, the page never calls it, and no command maps to this capability |
|
||||
| `display.brightness` (per-window dim, Tier 0) | ✅ `set_brightness` → `setWindowBrightness`, no privilege needed | ❌ | ❌ | ❌ |
|
||||
|
||||
⚠️ `PlayerCapabilities.kt` **does not declare `display.brightness`**, though `MainActivity` handles
|
||||
`set_brightness` unconditionally. So an *updated* Android panel loses the per-window dim slider that
|
||||
an un-updated one keeps via the baseline. See gap 2.
|
||||
|
||||
## Remote view and control
|
||||
|
||||
| capability | Android | Web | Tizen | BrightSign |
|
||||
|---|---|---|---|---|
|
||||
| `remote.screenshot` | ⚠️ view capture always; full-screen only with accessibility or MediaProjection | ⚠️ canvas only — same-origin content, and the alpha probe rejects frames where no pixels arrived | ✅ `captureAndSend` | ⚠️ host framebuffer capture **requires primary storage**; falls back to canvas, which cannot read the video plane |
|
||||
| `remote.stream` | ✅ | ✅ 1fps | ✅ | ⚠️ as web |
|
||||
| `remote.input` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `remote.screenshot` | ⚠️ `captureView` always (a real frame of the player's own view); full-screen only with accessibility or MediaProjection. Declared **only** for the full-screen path | ⚠️ canvas only — same-origin content, and the alpha probe rejects frames where no pixels arrived | ⚠️ `captureAndSend` captures **images only**; video and YouTube get an honest status card reading "Live preview unavailable for video / YouTube on Tizen" | ⚠️ `st-bridge.js` gates host framebuffer capture on **primary storage**; without a disk it falls back to canvas, which cannot read the video plane |
|
||||
| `remote.stream` | ✅ | ✅ 1fps | ✅ 1s interval over `captureAndSend`, so the same image-only limit | ⚠️ as web |
|
||||
| `remote.input` | ✅ `TouchInjector` — plain `dispatchTouchEvent`, no privilege | ✅ | ✅ `elementFromPoint().click()` + D-pad/volume keys | ✅ synthesised DOM events, needs no host |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| capability | Android | Web | Tizen | BrightSign |
|
||||
|---|---|---|---|---|
|
||||
| `system.restart_player` | ✅ | ✅ `location.reload()` | ✅ | ✅ host rebuilds the widget — a page reload does not reliably return |
|
||||
| `system.reboot` | ✅ device owner | ❌ a browser tab cannot reboot its host | ❌ no Tizen API exposed to the app | ✅ `RebootSystem()` via the host |
|
||||
| `system.self_update` | ✅ APK OTA (`UpdateChecker`) | ❌ the server deploys the player; there is nothing for it to update | ❌ `.wgt` updates go through Tizen's own store/CLI | ✅ `autorun.zip` package update |
|
||||
| `system.restart_player` | ✅ `launch` / `refresh` | ✅ `location.reload()` | ✅ `location.reload()` via `STDeviceControl` | ⚠️ needs `hasHost()` so the host rebuilds the widget. **A page-initiated reload does not reliably bring an roHtmlWidget back** — that darkened a customer's panel on 2026-07-28, which is why neither `st-bridge.js` nor the baseline offers this without a host |
|
||||
| `system.reboot` | ⚠️ **device owner only** (`STPolicy.reboot()`). Off-owner it degrades to an accessibility power *dialog*, which needs someone at the screen | ❌ a browser tab cannot reboot its host | ⚠️ only on a **partner-signed** panel where `STDeviceControl.capabilities().reboot` is true | ⚠️ `RebootSystem()` via the host |
|
||||
| `system.self_update` | ✅ APK OTA (`UpdateChecker`), and `update` forces a check | ❌ the server deploys the player; there is nothing for it to update | ❌ a `.wgt` is installed by the panel, not the app | 💀 **for the dashboard button.** The host really does self-update — `autorun.brs` polls `CheckPackageUpdate` every `PKG_CHECK_MS` — but that is a host-side poll on a socket it is not listening to. The page declares `system.self_update` behind `hasHost()`, the dashboard renders "Force update", and `index.html` has **no `update` branch at all**. See gap 3 |
|
||||
|
||||
## Device management
|
||||
|
||||
Android device-owner territory. Everything here is ❌ elsewhere for the same reason — no equivalent
|
||||
privilege model exists on those platforms — so the column is collapsed.
|
||||
privilege model exists on those platforms — so the column is collapsed. Tizen and BrightSign both
|
||||
decline these explicitly and in writing in their own capability modules.
|
||||
|
||||
| capability | Android | Web / Tizen / BrightSign |
|
||||
|---|---|---|
|
||||
| `system.kiosk` | ✅ lock-task, now persisted across reboot | ❌ no device-owner concept |
|
||||
| `system.brightness` | ✅ Tier 0/1 | ❌ |
|
||||
| `system.screen_timeout` | ✅ Tier 1 | ❌ |
|
||||
| `system.install_apk` | ✅ Tier 2 | ❌ not an APK platform |
|
||||
| `system.shell` | ✅ Tier 2, handled in `WebSocketService` | ❌ |
|
||||
| `system.time` | ✅ Tier 2 | ❌ |
|
||||
| `system.kiosk` | ⚠️ owner-only. Off-owner `startLockTask()` is screen pinning, which prompts — unusable on a panel with no input | ❌ no device-owner concept |
|
||||
| `system.brightness` | ⚠️ `WRITE_SETTINGS` **or** owner (`setSystemSetting`) | ❌ |
|
||||
| `system.screen_timeout` | ⚠️ same gate as above | ❌ |
|
||||
| `system.install_apk` | ⚠️ owner **or** a foreign DPC that delegated the install scope | ❌ not an APK platform |
|
||||
| `system.shell` | ✅ declared unconditionally — it is an **app-UID** `sh -c`, not root, so it works at any tier. Handled in `WebSocketService` | ❌ |
|
||||
| `system.time` | ⚠️ owner-only | ❌ |
|
||||
| `system.device_owner` | 💀 **declared by nobody.** See the red section above | ❌ |
|
||||
|
||||
## Synchronisation and resilience
|
||||
|
||||
| capability | Android | Web | Tizen | BrightSign |
|
||||
|---|---|---|---|---|
|
||||
| `sync.clock` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ SyncManager, BOS 8.2.10+; multicast so all members must share one L2 network |
|
||||
| `offline.cache` | ✅ content downloaded to disk, **resumable** (Range + If-Range), revision-keyed | ✅ service worker, **resumable chunked prefetch**, revision-keyed | ✅ **media cached to `wgt-private`** (`js/media-cache.js`), resumable, revision-keyed — declared at runtime, since a build with no writable private storage must not claim it | ✅ inherits the web player's service worker |
|
||||
| `sync.clock` | ✅ `GroupScheduleController` | ✅ | ✅ `syncedNow()` + `schedule-eval.js` | ✅ as web |
|
||||
| `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ `st-sync.js` / SyncManager, gated on module presence **and** BOS 8.2.10+ (below the floor the module can resolve and silently do nothing, which on a wall means every panel reports healthy while drifting). ❓ **unverified on hardware** |
|
||||
| `offline.cache` | ✅ `ContentCache` + `DownloadCoordinator`, resumable (Range/If-Range), revision-keyed | ✅ service worker, resumable chunked prefetch, revision-keyed; declared only when a worker is genuinely **controlling** the page | ⚠️ `js/media-cache.js` caches media to `wgt-private` — **new at HEAD**, absent from the fielded build, and declared at runtime only where the platform grants storage | ❓ **unverified.** See gap 4 |
|
||||
|
||||
---
|
||||
|
||||
## Where the four declaration sites disagree with each other
|
||||
|
||||
| | Android | Web | Tizen | BrightSign |
|
||||
|---|---|---|---|---|
|
||||
| declaration site | `telemetry/PlayerCapabilities.kt` | `declaredCapabilities()` in `index.html` | `js/capabilities.js` | **`index.html` again** |
|
||||
|
||||
⚠️ **`brightsign/st-bridge.js` `computeCapabilities()` IS DEAD CODE.** It is exported as
|
||||
`BS.capabilities`, and nothing calls it: `grep -n "BS\.[a-zA-Z]*(" server/player/index.html` lists
|
||||
23 bridge calls and `capabilities` is not among them. The BrightSign declaration actually comes
|
||||
from the web player's `declaredCapabilities()`, and the two disagree substantially:
|
||||
|
||||
| capability | `st-bridge.js` says | `index.html` actually declares | which is right |
|
||||
|---|---|---|---|
|
||||
| `offline.cache` | `navigator.serviceWorker` **exists** | a worker is **controlling** the page | index.html. The bridge's version is the exact lie that shipped on the XT245 |
|
||||
| `remote.screenshot` | needs `probe.storage_present` | any 2d canvas | the bridge. A canvas cannot read the video plane |
|
||||
| `remote.stream` | needs `probe.storage_present` | unconditional | the bridge |
|
||||
| `system.self_update` | needs `probe.storage_present` | needs `hasHost()` | the bridge — staging `autorun.zip` needs a volume |
|
||||
| `display.rotation` | needs a host (`roVideoMode`) | unconditional (CSS) | the bridge, for video |
|
||||
| `display.power` | needs `CecClass` | needs `hasHost()` | roughly equivalent |
|
||||
| `display.resolution` | host **or** `VideoOutputClass` | needs `hasHost()` | the bridge |
|
||||
| `system.restart_player` | needs a host | unconditional | the bridge — see the 2026-07-28 incident |
|
||||
| `sync.native` | module **and** OS ≥ 8.2.10 | `ScreenTinkerBSSync.available()`, which is **module presence only** | the bridge. `index.html` skips the firmware floor |
|
||||
|
||||
**`server/test/brightsign-capabilities.test.js` is 199 lines of thorough tests for this dead
|
||||
function.** Every one passes, and none of them constrains what a BrightSign actually declares. That
|
||||
is worse than no coverage: it reads as proof.
|
||||
|
||||
The fix is small and belongs to whoever owns those files — have `declaredCapabilities()` return
|
||||
`BS.capabilities()` when `BS.isBrightSign()`, and the storage/firmware gating that was already
|
||||
written and tested starts being true.
|
||||
|
||||
---
|
||||
|
||||
## Real gaps worth closing
|
||||
|
||||
Ordered by how visible the failure is to an operator.
|
||||
Prioritised by how visible the failure is to an operator.
|
||||
|
||||
1. **Tizen `audio.volume` — dead control.** `set_volume` has no handler in `tizen/js/app.js`; the
|
||||
only volume path is the on-device `KEYCODE_VOLUME_*` keys. The dashboard slider silently does
|
||||
nothing. Either implement the handler or let the capability hide the control.
|
||||
2. ~~**Tizen `offline.cache` is partial.**~~ **Closed.** `tizen/js/media-cache.js` caches the
|
||||
media itself to `wgt-private` — resumable, so a panel on a bad link accumulates an asset
|
||||
across attempts instead of restarting from zero, and revision-keyed, so a replaced asset is
|
||||
still a miss. The capability is declared at runtime rather than assumed: a build that cannot
|
||||
write to private storage keeps quiet about it.
|
||||
3. **BrightSign `remote.screenshot` needs primary storage.** Reachable today only via the canvas
|
||||
fallback, which cannot read the video plane, so screenshots show everything except the video.
|
||||
Resolves itself when a card or SSD is fitted.
|
||||
4. **`display.resolution` is BrightSign-only.** Fine, but the dashboard should not offer it
|
||||
elsewhere.
|
||||
**Gaps 1, 2 and 5 are closed** (gap 1 shipped in 1.9.31; 2 and 5 are on
|
||||
`fix/player-parity-small-gaps` and unreleased). ⚠️ **The baselines below have deliberately NOT been
|
||||
moved** — per the rule in this document a baseline entry moves when the fix *reaches displays*,
|
||||
which is the release AFTER the one carrying it. Moving them together would grant a capability to
|
||||
every panel still running the old build.
|
||||
|
||||
⚠️ **Gaps 3 and 4 were implemented, audited, and REVERTED.** Both are still open, and both are now
|
||||
known to be considerably more expensive than "small". Their rows record what the audit found, so the
|
||||
next attempt starts from the traps rather than rediscovering them.
|
||||
|
||||
| # | gap | difficulty | why it matters |
|
||||
|---|---|---|---|
|
||||
| 1 | ✅ **`set_volume` payload mismatch** (`server/player/index.html`, `tizen/js/app.js`) — accept `level` (0..1) alongside `value` (0..100). | **trivial** — one line each | The volume slider is dead on 3 of 4 players. Highest visibility, lowest cost in the list. **Fixed and released in 1.9.31** (`volumeLevelFromCommand()`). |
|
||||
| 2 | ✅ **`PlayerCapabilities.kt` under-declares.** Add `display.brightness` (Tier 0, `setWindowBrightness`, always available) and `system.device_owner` under `if (isOwner)`. | **trivial** | Updating an Android panel currently *loses* it the per-window dim slider, and keeps the Tier-2 stand-in in `player-capabilities.js` necessary. **Fixed** — both declared; the `system.kiosk` stand-in can retire one release after this ships. **The gap was wider than written**: `remote.screenshot` and `remote.stream` were gated on the accessibility service while `captureScreen()` falls through to `ScreenshotCapture.captureView`, a plain view draw with no permission check — so a Tier-0 panel *lost live view and screenshots by updating*, and a granted MediaProjection never became a capability at all (nothing re-declares on consent, so the operator granted it, capture started, and the server went on refusing). Both are now unconditional, matching the baseline's own reasoning. `display.power` remains conditional on purpose — see the DELIBERATE list in `player-parity-baselines.test.js`. |
|
||||
| 3 | ❌ **REVERTED — BrightSign "Force update" is a dead button.** `index.html` has no `update` branch; the host self-updates on its own poll. | **NOT small — needs host work first** | Wiring the button to `CheckPackageUpdate` was tried and withdrawn. The update path is **synchronous and unbounded** (no `SetTimeout` on either transfer), so a slow failing download blocks the message loop past `WATCHDOG_MS` (120s), fabricating a crash event and rebuilding the widget. Worse, `MAX_ATTEMPTS_PER_VERSION` is 3 and the counter carries **no version binding**, so three presses on a bad link refuse that panel *every future version* until someone clears the registry by hand. `cfg.self_update` is not in the probe payload either, so an opted-out fleet shows a button guaranteed to do nothing while the dashboard toasts success — and `update` is allowed as a **group broadcast**, so one click can start N synchronous downloads. Prerequisites: a transfer timeout under the watchdog, a version-bound (or manual-exempt) attempt counter, a result message so the toast can tell the truth, and `self_update` in the probe. Until then, **withdrawing the claim is the cheaper honest fix.** |
|
||||
| 4 | ❌ **REVERTED — `declaredCapabilities()` should defer to `BS.capabilities()` on BrightSign.** | **NOT small — the bridge's list is not a superset** | Deferring wholesale was tried and withdrawn: the two lists disagree in **both** directions. The bridge gates `remote.screenshot`/`remote.stream` on `storage_present` alongside `system.self_update`, but that reasoning is stale — the player captures via `@brightsign/screenshot` into **RAM** (`/tmp`) and falls back to canvas, so both work with no disk and deferring *removes working controls*. Only `system.self_update` is genuinely storage-gated. The bridge also declares `playback.transitions` unconditionally, where the page checks `transitionRuntimeReady()` — an over-declare on the one platform where the UMD/`nodejs_enabled` collision silently kills transitions. And `offline.cache` gets *looser*, not stricter: the bridge omits the page's `swRegistrationFailed` check. Compounding all of it, `probeHost`'s 3s timeout sets `answered = true`, so a late `probe-result` is discarded **for the page's lifetime** — and `autorun.brs` runs a blocking update check *before* entering the message loop, making a >3s answer plausible. A correct fix is a per-capability MERGE, not a wholesale hand-off, plus fixing the probe timeout. |
|
||||
| 5 | ✅ **The capture-bootstrap button is hidden where it is needed** (`device-detail.js`, `can('remote.screenshot')`). | **small** | Server-side gating is fixed; the UI half is not. **Fixed** via `isAndroidDevice()`, mirroring `platformFamily()` in `server/lib/player-capabilities.js` — all four signals in the same order, since a Tizen TV registers `android_version: 'Tizen 6.5'` and an Android-test-only helper classifies every Samsung panel as Android. ⚠️ The gate is Android-and-nothing-else, **not** "Android that lacks `remote.screenshot`": `/api/devices/:id` ships `capabilitiesFor()`, which flattens declared and baseline into one array, and the android baseline *contains* `remote.screenshot` — so that condition hides the button from all ~440 undeclared panels. The dashboard cannot currently distinguish "declared" from "baseline-filled" at all; if a future gate needs that, the API must expose the raw declaration. |
|
||||
| 6 | **Tizen `remote.screenshot` is images-only.** Video and YouTube return a status card. AVPlay has no readable surface for a canvas. | **hard**, possibly impossible | Honest today, but an operator checking a video panel gets a card instead of a picture. |
|
||||
| 7 | **BrightSign transitions/PiP over video.** DOM composited over a hwz hardware plane may be invisible. The likely fix is `roVideoMode.SetGraphicsZOrder("front")`, deliberately not applied blind. | **medium**, ❓ **needs hardware** | Changing z-order blind risks hiding video entirely on a player that currently works. |
|
||||
| 8 | **BrightSign offline caching is unproven either way.** See below. | ❓ **needs hardware** | |
|
||||
|
||||
### The BrightSign `offline.cache` question, stated honestly
|
||||
|
||||
A real XT245 on alpha exposes `navigator.serviceWorker`, and then never even fetches `sw.js`:
|
||||
registration is refused, so there is no worker, no content cache and no offline playback. That unit
|
||||
runs **BSN Supervisor** (`autorun.createdby = Supervisor 2.1.18.3`) rather than our
|
||||
`brightsign/autorun.brs`, and Supervisor's widget has no `storage_path` — the setting our own host
|
||||
script does set (`storage_path: "/cache"`, `storage_quota: "1073741824"`) and the precondition for a
|
||||
widget having persistent storage at all.
|
||||
|
||||
So this is *very likely* a widget CONFIG issue rather than a platform limit. **It is unverified: no
|
||||
one has yet watched a player running our package register a worker.** Until someone has, this
|
||||
document does not claim it, the `brightsign` baseline does not grant it, and the player declares it
|
||||
only when a worker is genuinely in control — a refused registration reports
|
||||
`app_error/sw_unavailable` to the server rather than a `console.warn` on a display nobody has a
|
||||
console for.
|
||||
|
||||
## Correctly impossible — do not "fix" these
|
||||
|
||||
- **`system.reboot` on web/Tizen.** No API exists. A browser tab rebooting its host would be a
|
||||
browser vulnerability.
|
||||
- **`system.reboot` on web.** No API exists. A browser tab rebooting its host would be a browser
|
||||
vulnerability.
|
||||
- **`display.power` on web.** The overlay is the honest maximum; the panel stays lit.
|
||||
- **All of device management off Android.** No equivalent privilege model exists on Tizen or
|
||||
BrightSign, and a web player has no device to manage.
|
||||
- **Device management off Android.** No equivalent privilege model exists on Tizen or BrightSign,
|
||||
and a web player has no device to manage.
|
||||
- **`system.self_update` on web.** The player *is* the deployment; there is nothing to update.
|
||||
- **`sync.native` off BrightSign.** It is BrightSign's own protocol, and the clock-derived one is
|
||||
the cross-platform answer that already works everywhere.
|
||||
- **`display.resolution` off BrightSign.** No other platform exposes mode setting to an app.
|
||||
|
||||
## ⚠️ Corrections needed in `player-capabilities.js`
|
||||
---
|
||||
|
||||
Found while verifying this table. The baselines only apply to displays that declare nothing, so
|
||||
these are wrong for the existing fleet until each player ships its declaration:
|
||||
## Baselines: what an un-updated display is assumed to be able to do
|
||||
|
||||
- **`tizen` claims `audio.volume`** — no handler exists (gap 1 above). Should be removed.
|
||||
- **`tizen` omits `remote.screenshot` and `remote.stream`** — both are implemented
|
||||
(`captureAndSend`, `startStreaming`). Should be added.
|
||||
- **`tizen` declares `offline.cache` itself now** — the server baseline still omits it, which is
|
||||
correct: a fielded panel that has not been updated genuinely cannot hold media, and the
|
||||
baseline describes what an un-updated one can do.
|
||||
~446 fielded displays declare nothing and fall back to `BASELINE` in
|
||||
`server/lib/player-capabilities.js`. Because v1.9.29 is the first build in which *any* player
|
||||
declares anything, every display reading a baseline is running **v1.9.28 or older by construction**
|
||||
— so each entry below is justified against `git show v1.9.28:<player source>`, not against HEAD.
|
||||
|
||||
`server/test/player-parity-baselines.test.js` pins these to the player sources.
|
||||
|
||||
### Corrections made in this pass
|
||||
|
||||
| baseline | change | evidence |
|
||||
|---|---|---|
|
||||
| `android` | **removed `display.power`** | v1.9.28 `MainActivity`: `"screen_on" -> Log.w("no privileged wake path on a non-rooted panel — no-op")`. The ON half is dead on 100% of fielded panels, and one capability renders **both** buttons. |
|
||||
| `android` | **removed `system.reboot`** | `STPolicy.reboot()` requires device owner; off-owner v1.9.28 shows the accessibility power *dialog* — which on the accessibility-enabled panels common in this fleet paints that dialog **over the signage**. Owner provisioning is unreleased (#161 / PR #168 still open), so "device owner AND pre-1.9.29" is effectively an empty set. |
|
||||
| `tizen` | **added `display.power`** | v1.9.28 `app.js` implements both halves with no signing and no panel API: `showScreenOff()` / `clearScreenOff()` + `keepAwake()`. Unlike Android, neither half is privilege-gated. Withholding it hid a working control on every Tizen panel. |
|
||||
| `web` | **removed `audio.volume`, then RESTORED it in 1.9.31** | Removed when v1.9.28 `index.html` contained the string `set_volume` zero times. Restored once the handler landed — this player is served by the server, so there is no fielded build to lag behind. |
|
||||
| `brightsign` | **removed `display.power`, `system.reboot`, `system.restart_player`, `offline.cache`** (and `audio.volume`, restored in 1.9.31 with `web`) | All five need a host bridge (`hasHost()`) or a service worker that a Supervisor-built widget refuses. `system.restart_player` is the 2026-07-28 panel-blackout path. `offline.cache` is the documented lie this whole model exists to stop. |
|
||||
|
||||
### When a baseline may move
|
||||
|
||||
A baseline describes what an **un-updated** display can do, so the question "has this shipped?"
|
||||
has two different answers depending on how the player reaches the screen.
|
||||
|
||||
**Served by the server — `web`, `brightsign`.** The player is a document this server hands out. A
|
||||
display running against this build *is* running this build's player; there is no such thing as a
|
||||
browser panel stuck on last release's. So the baseline moves the moment the server ships the fix,
|
||||
and holding it back hides a control that already works. `test/player-parity-baselines.test.js`
|
||||
judges these two against the working tree, in **both** directions.
|
||||
|
||||
**Shipped as a device artifact — `android`, `tizen`.** The player is an APK or a `.wgt` sitting on
|
||||
the panel. Cutting a release puts nothing on any screen; a panel updates when somebody updates it,
|
||||
and this repo cannot know how many are still back on which build. These are judged against the
|
||||
**previous release**, and only in the over-claim direction: "the baseline claims it, so the shipped
|
||||
player had better implement it" is always worth failing on, while "HEAD gained the handler, so add
|
||||
it to the baseline" is a guess about the fleet, not a fact about it. A panel that HAS updated
|
||||
declares its own capabilities and never reads the baseline at all.
|
||||
|
||||
The cost of the one-directional rule is that a stale entry can sit here after the artifact really
|
||||
has reached the fleet. That is a judgement call about panels, so a person makes it in
|
||||
`server/lib/player-capabilities.js` and records why — which is what the Tizen `audio.volume` note
|
||||
there is doing right now.
|
||||
|
||||
> This distinction was learned the hard way. The test used to read "shipped" as *the newest tag*,
|
||||
> which is HEAD on a release commit — so tagging 1.9.31 flipped every biconditional at once and
|
||||
> demanded a baseline change for displays that could not possibly have the fix yet. The build went
|
||||
> red naming a baseline, with nothing in the diff to explain it.
|
||||
|
||||
### Consequence, deliberately accepted
|
||||
|
||||
`server/services/scheduler.js` gates the nightly scheduled reboot on `system.reboot`. Removing it
|
||||
from the Android baseline means scheduled reboots now **no-op for undeclared Android panels**
|
||||
instead of logging `scheduled reboot fired` for a panel that never rebooted. That log line is the
|
||||
stated reason the gate exists; skipping is the honest answer, and an owner panel on v1.9.29+
|
||||
declares `system.reboot` for itself and is unaffected.
|
||||
|
||||
### The resulting baselines
|
||||
|
||||
| capability | android | tizen | brightsign | web |
|
||||
|---|---|---|---|---|
|
||||
| `playback.*` (all 7) | ✅ | ✅ | ✅ | ✅ |
|
||||
| `audio.mute` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `audio.volume` | ✅ | ❌ the fielded `.wgt` has no handler | ✅ since 1.9.31 (runs the served player) | ✅ since 1.9.31 |
|
||||
| `display.rotation` | ✅ | ✅ | ⚠️ graphics only | ✅ |
|
||||
| `display.power` | ❌ `screen_on` is a no-op | ✅ | ❌ needs host | ❌ |
|
||||
| `display.brightness` | ✅ Tier 0, since v1.9.10 | ❌ | ❌ | ❌ |
|
||||
| `remote.screenshot` / `remote.stream` | ✅ view capture | ✅ images only | ❌ no video plane | ✅ |
|
||||
| `remote.input` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `system.restart_player` | ✅ | ✅ | ❌ widget may not return | ✅ |
|
||||
| `system.self_update` | ✅ | ❌ | ❌ needs host | ❌ |
|
||||
| `system.reboot` | ❌ owner-only | ❌ | ❌ needs host | ❌ |
|
||||
| `sync.clock` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `offline.cache` | ✅ | ❌ playlist JSON only | ❌ ❓ unverified | ✅ |
|
||||
|
||||
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.device_owner`, `sync.native`, `display.resolution` — is absent from **every** baseline, and
|
||||
a test enforces that.
|
||||
|
||||
## What is tested, and what cannot be
|
||||
|
||||
`server/test/player-parity-baselines.test.js` reads the player sources and fails when they and the
|
||||
claims disagree:
|
||||
|
||||
- every baseline and command-map name is in the vocabulary, and no baseline has duplicates;
|
||||
- **the dead-button rule** — every gated command has a branch in some player;
|
||||
- **the unreachable-capability rule** — every gating capability is either declared by some player's
|
||||
source or granted by some baseline. *This is the test that would have caught the
|
||||
`system.device_owner` bug*;
|
||||
- a device-owner Android panel can actually be sent all five Tier-2 commands, and an ordinary one is
|
||||
still refused them **by name**;
|
||||
- `audio.volume` and `offline.cache` are **biconditional** against the player sources, so a fix in a
|
||||
player fails the test until the baseline is updated;
|
||||
- no baseline claims a conditional capability, and the BrightSign baseline claims nothing behind
|
||||
`hasHost()`;
|
||||
- every capability-shaped string quoted in any player is one the server knows — the server's parser
|
||||
*drops* unknown names, so a typo silently removes a control rather than raising anything.
|
||||
|
||||
**Not testable from source, and asserted nowhere:** whether CEC reaches a real display; whether a
|
||||
widget built by our own `autorun.brs` is permitted to register a service worker; whether SyncManager
|
||||
genuinely holds a wall in frame lock; whether transitions and PiP are visible over a hwz video
|
||||
plane. Each is marked ❓ above and needs hardware.
|
||||
|
|
|
|||
263
docs/sso-setup.md
Normal file
263
docs/sso-setup.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# Single sign-on — setup guide
|
||||
|
||||
How to turn on SSO, for the two people who need it: the **operator** running the server, and an
|
||||
**organization admin** bringing their company's own identity provider.
|
||||
|
||||
Everything below is OpenID Connect. One flow — Authorization Code with PKCE, completed server-side —
|
||||
so the browser never talks to the provider directly and there is no SDK to load.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Which kind of SSO do you want?](#which-kind-of-sso-do-you-want)
|
||||
- [Operator: Google](#operator-google)
|
||||
- [Operator: Microsoft / Entra ID](#operator-microsoft--entra-id)
|
||||
- [Operator: any other provider](#operator-any-other-provider)
|
||||
- [Organization admin: bring your own provider](#organization-admin-bring-your-own-provider)
|
||||
- [Requiring SSO for your organization](#requiring-sso-for-your-organization)
|
||||
- [Linking an existing account](#linking-an-existing-account)
|
||||
- [What users see at sign-in](#what-users-see-at-sign-in)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Which kind of SSO do you want?
|
||||
|
||||
There are two, and they are configured in completely different places.
|
||||
|
||||
| | Instance-wide | Per-organization |
|
||||
|---|---|---|
|
||||
| Configured by | the **operator**, in environment variables | an **org owner/admin**, in Settings → Single sign-on |
|
||||
| Restart needed | yes | no |
|
||||
| Who sees the button | everyone, on the login page | only people at that organization's **verified** domains |
|
||||
| Typical use | "Sign in with Google" for anyone | a customer wiring up their own Entra/Okta tenant |
|
||||
|
||||
An organization's provider **overrides** the instance's for its own verified domains, and never
|
||||
appears publicly — the login page reveals it only after someone enters an address at one of those
|
||||
domains, so a guessed domain cannot confirm who your customers are.
|
||||
|
||||
---
|
||||
|
||||
## Operator: Google
|
||||
|
||||
Google is the simplest: one fixed issuer, and it reports whether an address is verified.
|
||||
|
||||
1. **console.cloud.google.com** → create a project (a dedicated one — if you reuse an auto-created
|
||||
AI Studio project and later tidy those up, you take sign-in down with it).
|
||||
2. **Google Auth Platform** (formerly "OAuth consent screen"):
|
||||
- **App name** — users see this on the consent screen
|
||||
- **Audience**: External
|
||||
- Support and contact email
|
||||
- Scopes: nothing to add. `openid`, `email` and `profile` are implicit and non-sensitive, so
|
||||
**no Google verification review is required**.
|
||||
3. **Clients → Create client → Web application**
|
||||
- **Authorized redirect URI**, exactly:
|
||||
```
|
||||
https://your-domain.example/api/auth/oidc/google/callback
|
||||
```
|
||||
- Leave *Authorized JavaScript origins* empty — the exchange is server-side.
|
||||
4. **Audience → Test users**: while publishing status is *Testing*, only listed accounts can sign
|
||||
in. Add yourself, or **Publish app** (safe here, given the scopes).
|
||||
5. Set the environment:
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=…apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=… # a Web application client needs one
|
||||
```
|
||||
|
||||
> Google matches redirect URIs byte for byte. No trailing slash, `https` not `http`.
|
||||
|
||||
---
|
||||
|
||||
## Operator: Microsoft / Entra ID
|
||||
|
||||
Microsoft needs one decision up front: **whose accounts are signing in?** That decides both the app
|
||||
registration and, crucially, the tenant ID you configure.
|
||||
|
||||
### The rule that catches everyone
|
||||
|
||||
`MICROSOFT_TENANT_ID` is **not** "where the app is registered". It is the directory that
|
||||
**authenticates the user**, because it is what the ID token's `iss` will say. Those are different
|
||||
things whenever the two differ — most obviously for personal accounts.
|
||||
|
||||
| Who signs in | Supported account types | `MICROSOFT_TENANT_ID` |
|
||||
|---|---|---|
|
||||
| Personal Microsoft accounts (outlook.com, hotmail, …) | **Personal Microsoft account users** | `9188040d-6c67-4c5b-b112-36a304b66dad` (Microsoft's consumer directory) |
|
||||
| Your own staff | **Single tenant** | your **Directory (tenant) ID** |
|
||||
|
||||
⚠️ **`common`, `organizations` and `consumers` are refused, deliberately.** Two reasons that point
|
||||
the same way. They cannot work: Microsoft's multi-tenant metadata advertises the issuer as the
|
||||
literal template `https://login.microsoftonline.com/{tenantid}/v2.0`, so `iss` can never match. And
|
||||
the obvious workaround is dangerous — accepting that template means accepting tokens from *every*
|
||||
Azure tenant, which is [nOAuth](https://www.descope.com/blog/post/noauth): any tenant admin can set
|
||||
an arbitrary, unverified `email` on one of their own users and be issued a session as that address.
|
||||
Safe multi-tenant support needs per-tenant pinning (allowlist `tid`, key accounts on `oid`+`tid`
|
||||
rather than email) and is not implemented. Setting one of these disables Microsoft sign-in with a
|
||||
warning at boot rather than failing quietly.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **portal.azure.com** → Entra ID → App registrations → **New registration**
|
||||
- **Name** — users see this on the consent screen
|
||||
- **Supported account types** — per the table above
|
||||
- **Redirect URI**: platform **Web**, value
|
||||
```
|
||||
https://your-domain.example/api/auth/oidc/microsoft/callback
|
||||
```
|
||||
⚠️ **Web, not SPA.** A SPA registration is rejected at the token endpoint, because this exchange
|
||||
runs server-side and sends no browser `Origin`.
|
||||
2. **Certificates & secrets → New client secret** → copy the **Value** (shown once, not the ID).
|
||||
A Web registration is a confidential client; the exchange fails without it.
|
||||
3. **Token configuration → Add optional claim → ID → `email`.** Without it the token can arrive with
|
||||
no address at all, which fails as `no_email`.
|
||||
4. Set the environment:
|
||||
```bash
|
||||
MICROSOFT_CLIENT_ID=…
|
||||
MICROSOFT_TENANT_ID=… # see the table — NOT necessarily the directory the app lives in
|
||||
MICROSOFT_CLIENT_SECRET=…
|
||||
```
|
||||
|
||||
> **Entra never sends `email_verified`.** ScreenTinker treats a tenant-pinned Microsoft provider as
|
||||
> vouching for the address rather than demanding a claim Microsoft does not emit — safe because the
|
||||
> operator chose that provider and it is pinned to one directory. An explicit `email_verified: false`
|
||||
> is still refused.
|
||||
|
||||
---
|
||||
|
||||
## Operator: any other provider
|
||||
|
||||
Okta, Auth0, Keycloak, Authentik, Zitadel — anything with a discovery document:
|
||||
|
||||
```bash
|
||||
OIDC_PROVIDERS=okta,authentik # comma-separated slugs
|
||||
OIDC_OKTA_ISSUER=https://example.okta.com # the base URL whose /.well-known/openid-configuration describes it
|
||||
OIDC_OKTA_CLIENT_ID=…
|
||||
OIDC_OKTA_CLIENT_SECRET=… # optional — PKCE means a public client works
|
||||
OIDC_OKTA_NAME=Okta # optional button label
|
||||
OIDC_OKTA_SCOPES=openid email profile # optional
|
||||
OIDC_OKTA_ASSUME_EMAIL_VERIFIED=true # only if it verifies addresses but omits the claim
|
||||
```
|
||||
|
||||
Redirect URI is `https://your-domain.example/api/auth/oidc/<slug>/callback`.
|
||||
|
||||
Set **`APP_URL`** so the redirect URI is pinned to one origin. It must match your provider's
|
||||
registration exactly, and deriving it from the request `Host` would both break behind a second
|
||||
hostname and take its value from the caller.
|
||||
|
||||
---
|
||||
|
||||
## Organization admin: bring your own provider
|
||||
|
||||
No environment variables, no restart, no operator involvement.
|
||||
|
||||
1. **Settings → Single sign-on → Add provider**
|
||||
- **Issuer** — for Entra, `https://login.microsoftonline.com/<your-tenant-guid>/v2.0`
|
||||
- **Client ID** and **Client secret** from your own app registration
|
||||
- **Email domains** you intend to claim
|
||||
2. Copy the **redirect URI** shown in Settings and register it with your provider. It carries a
|
||||
generated slug, so two customers can neither collide on nor guess each other's.
|
||||
3. **Verify each domain.** Publish the TXT record shown:
|
||||
```
|
||||
_screentinker-verify.<your-domain> TXT st-verify=<token>
|
||||
```
|
||||
Then press Verify. An unverified claim lapses after 8 hours and releases the domain.
|
||||
|
||||
Your provider may only assert addresses at domains you have **proved** you control. A domain can be
|
||||
claimed by one organization only; a second claim is refused.
|
||||
|
||||
> Proof by CNAME is not accepted — it would need a wildcard zone we do not operate, and would turn a
|
||||
> subdomain takeover into an apex takeover.
|
||||
|
||||
Once a domain is verified, your provider is trusted to assert addresses in it even if it omits
|
||||
`email_verified` (as Entra does) — the DNS proof stands in for the claim. A provider that has
|
||||
verified nothing assumes nothing.
|
||||
|
||||
---
|
||||
|
||||
## Requiring SSO for your organization
|
||||
|
||||
**Settings → Single sign-on → Require single sign-on.** Then, for anyone at your verified domains:
|
||||
|
||||
- passwords are refused
|
||||
- other providers are refused, **including the instance's own Google/Microsoft** — otherwise
|
||||
"requires SSO" would just be renaming the bypass
|
||||
|
||||
⚠️ **Enabling this clears the passwords** of members at your verified domains. That is not reversible
|
||||
without a reset.
|
||||
|
||||
Turning it **off** requires a platform administrator to approve the request, so one compromised org
|
||||
admin cannot quietly reopen password login. Plan for that turnaround before you enable it.
|
||||
|
||||
---
|
||||
|
||||
## Linking an existing account
|
||||
|
||||
Signing in with a provider never takes over an account that already has a password — otherwise
|
||||
anyone who could get a provider to assert your address would inherit your account. Link it
|
||||
deliberately instead:
|
||||
|
||||
**Settings → Sign-in method → Link `<provider>`**
|
||||
|
||||
- An account has **one** credential. Linking **deletes** the password; afterwards you sign in with
|
||||
the provider only.
|
||||
- **Unlink** asks for a new password and applies both changes together, so the account is never left
|
||||
without a way in.
|
||||
- The provider account must use the **same email address** as the ScreenTinker account.
|
||||
- Only the providers this server offers can be linked — an organization's own provider cannot attach
|
||||
itself to an account.
|
||||
|
||||
> ⚠️ If you link the **platform administrator** account, that provider becomes the only way in.
|
||||
> Should it break, recovery is `scripts/reset-admin.js` on the server, not the login page.
|
||||
|
||||
---
|
||||
|
||||
## What users see at sign-in
|
||||
|
||||
The login page asks for an email address first and shows the password box only after you continue.
|
||||
That is what lets it check whether the address belongs to an organization with its own provider
|
||||
*before* offering a credential — so someone whose company requires SSO is shown that, rather than a
|
||||
password box that was going to be refused. Correcting the address takes you back a step.
|
||||
|
||||
The instance's own providers are shown throughout.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Errors appear as a message on the login page (or Settings, when linking). The exact code is in the
|
||||
URL as `sso_error=…`, and the server logs a matching `[oidc]` line with the underlying reason.
|
||||
|
||||
| Code | What it means | Usual cause |
|
||||
|---|---|---|
|
||||
| `unknown_provider` | No such provider on this server | Slug typo; or `MICROSOFT_TENANT_ID` is multi-tenant, so Microsoft was disabled at boot — check the `[sso]` warning |
|
||||
| `provider_unavailable` | Discovery or the token exchange failed | Wrong issuer URL; no outbound network; **missing client secret** on a confidential client |
|
||||
| `provider_refused` | The provider itself said no | Consent declined; conditional-access policy; account not on the Google test-user list |
|
||||
| `expired` | The round trip took too long | Left the tab open; started over in another tab |
|
||||
| `bad_state` / `no_code` | The response did not match the request | Started in one browser and returned in another; a redirect URI that does not match the registration |
|
||||
| `verification_failed` | The ID token did not verify | **Wrong tenant** — the log prints the `iss` actually seen; clock skew; wrong client ID |
|
||||
| `no_email` | The token carried no address | Entra: add the **`email`** optional claim under Token configuration |
|
||||
| `email_unverified` | The provider would not vouch for the address | The provider sent `email_verified: false`; or it omits the claim and is not eligible to assume (an org provider with no verified domain) |
|
||||
| `account_exists_local` | That address already has a password | Sign in with the password, then **Settings → Sign-in method → Link** |
|
||||
| `account_exists_other_provider` | The account belongs to a different provider | Unlink first, or sign in with the provider that owns it |
|
||||
| `subject_mismatch` | Same address, different provider subject | The address was reassigned. Deliberate: it stops a recycled mailbox inheriting an account |
|
||||
| `domain_not_allowed` | The provider asserted a domain it has not verified | Verify the domain, or check which address the provider is actually sending |
|
||||
| `sso_required` | The organization requires its own provider | Use the organization's button, not the password box or an instance provider |
|
||||
| `registration_disabled` | New accounts are turned off | The address has no account and self-registration is disabled |
|
||||
| `link_email_mismatch` | The provider account has a different address | Sign in to the provider with the same address as the account |
|
||||
| `link_already_used` | That provider identity is linked elsewhere | Unlink it from the other account first |
|
||||
|
||||
### Checks worth doing first
|
||||
|
||||
```bash
|
||||
# What the server thinks is configured (public endpoint)
|
||||
curl -s https://your-domain.example/api/auth/config
|
||||
|
||||
# Does the start URL carry the right issuer and redirect?
|
||||
curl -s -o /dev/null -D - https://your-domain.example/api/auth/oidc/google/start | grep -i location
|
||||
|
||||
# Boot warnings, and every login outcome
|
||||
docker logs <container> 2>&1 | grep -E '\[sso\]|\[oidc\]'
|
||||
```
|
||||
|
||||
`/api/auth/config` reporting `microsoftEnabled: false` while `MICROSOFT_CLIENT_ID` is set almost
|
||||
always means the tenant ID was rejected — look for the `[sso]` line at boot.
|
||||
112
docs/telemetry.md
Normal file
112
docs/telemetry.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Install statistics
|
||||
|
||||
ScreenTinker can optionally report how many screens an install runs. It is **off until you turn it
|
||||
on**, and this page documents the whole of it.
|
||||
|
||||
---
|
||||
|
||||
## What is sent
|
||||
|
||||
Three fields. This is the complete payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance_id": "9f2c1b6e-4a17-4c8e-9d3b-27a5e0f81c44",
|
||||
"version": "1.9.34",
|
||||
"screen_count": 42
|
||||
}
|
||||
```
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| `instance_id` | A random UUID generated by your server on first use and kept in its own database. It carries no information about you — its only job is to let two reports from the same server be recognised as the same server, so a count is a count rather than a sum of duplicates. |
|
||||
| `version` | The ScreenTinker version this server is running. |
|
||||
| `screen_count` | How many displays have been paired with this server. |
|
||||
|
||||
## What is not sent
|
||||
|
||||
No hostnames, IP addresses or domains. No organization, workspace or user names. No email
|
||||
addresses and no user count. No device names, locations or serial numbers. No content, filenames,
|
||||
playlists or schedules. No logs and no configuration.
|
||||
|
||||
The request is sent over HTTPS, and the receiving service does not record the source address.
|
||||
|
||||
## Verifying that
|
||||
|
||||
Rather than take the above on trust:
|
||||
|
||||
- **In the product** — Settings → Install statistics shows the exact payload your server would
|
||||
send, generated live from your own data, plus what it last actually sent and when.
|
||||
- **In the source** — the payload is built in one function, `payload()` in
|
||||
[`server/lib/telemetry.js`](../server/lib/telemetry.js). Every field that leaves your server
|
||||
is in that object literal. `server/test/telemetry.test.js` fails if a field is added.
|
||||
- **On the wire** — the destination is a single `POST`, overridable with `TELEMETRY_ENDPOINT`, so
|
||||
you can point it at your own collector and read exactly what arrives.
|
||||
|
||||
## Turning it on or off
|
||||
|
||||
You are asked once, on the dashboard, if you are a platform administrator. Both answers are
|
||||
remembered, so declining is permanent and you will not be asked again after an update.
|
||||
|
||||
To change your mind at any time: **Settings → Install statistics**.
|
||||
|
||||
Reports are sent 5 minutes after the server starts, then once a day while it keeps running.
|
||||
Nothing is queued or retried — if your server is offline or the request fails, that attempt is
|
||||
simply skipped.
|
||||
|
||||
## If your outbound traffic is filtered
|
||||
|
||||
Reports are an ordinary HTTPS `POST` from your server to:
|
||||
|
||||
```
|
||||
https://stats.screentinker.com/api/telemetry/report
|
||||
```
|
||||
|
||||
Many self-hosted servers sit on networks that block outbound connections by default. **If yours
|
||||
does, that address has to be allowed or the reports never arrive** — sharing will appear to be on
|
||||
while nothing reaches us.
|
||||
|
||||
You do not have to guess whether that is happening. Turning sharing on sends a report immediately,
|
||||
so a blocked connection is reported there and then, and **Settings → Install statistics** names the
|
||||
failure and the address to allow.
|
||||
|
||||
Nothing needs to be opened *inbound*. This is an outbound connection from your server only.
|
||||
|
||||
## Keeping your own copy
|
||||
|
||||
If you want these numbers for your own fleet, set `TELEMETRY_EXTRA_ENDPOINT` to your own collector.
|
||||
Your server then posts the same three fields there as well.
|
||||
|
||||
Two things to be clear about, because the naming is deliberate:
|
||||
|
||||
- **It is additional, not a redirect.** Setting it does not stop the shared report going to
|
||||
ScreenTinker — that is why it is called `EXTRA` rather than `ENDPOINT`. Settings lists every
|
||||
destination a report goes to, so what is configured is always visible.
|
||||
- **It is independent of the sharing switch.** Your collector receives reports whether sharing is
|
||||
on or off, because that is your server posting to your host. **If you want your own statistics
|
||||
and nothing sent to us, set it and leave sharing off** — that combination is supported on purpose.
|
||||
|
||||
Each destination is attempted separately, so one being unreachable never stops the other.
|
||||
|
||||
## Why we ask
|
||||
|
||||
ScreenTinker is self-hostable, so most installs are invisible to us by design, and that is how it
|
||||
should stay. The cost is that we genuinely cannot answer "how many screens run this?" — a question
|
||||
that matters for arguing the project is worth continuing to build, and for deciding which players
|
||||
deserve the next round of work.
|
||||
|
||||
Sharing is a small, specific way to help with that. Declining is a completely reasonable answer and
|
||||
changes nothing about how the product works.
|
||||
|
||||
> **A note on honesty.** Because sharing is opt-in, any total we publish is a **floor** — "at least
|
||||
> N screens" — never an estimate of the whole install base. Instances that opt in are not a random
|
||||
> sample of those that don't, so the number is not something to extrapolate from, and we won't.
|
||||
|
||||
## Running your own collector
|
||||
|
||||
Set `TELEMETRY_COLLECTOR=1` and this server accepts reports at `POST /api/telemetry/report`,
|
||||
storing them in a `telemetry_reports` table keyed by `instance_id`. The endpoint is inert unless
|
||||
that variable is set, so a normal install never exposes it.
|
||||
|
||||
Reports are upserted rather than appended — one row per install holding its latest report, not a
|
||||
growing event log.
|
||||
|
|
@ -291,10 +291,24 @@ body {
|
|||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.content {
|
||||
/* Wraps the (optional) banners strip and main content so they stack
|
||||
vertically as a single flex column, independent from the fixed sidebar.
|
||||
Without this wrapper, #banners and .content would be direct siblings in
|
||||
the (row-direction) body flexbox, turning the banner into a narrow flex
|
||||
item next to the content instead of a full-width strip above it, and
|
||||
shifting the whole dashboard layout out of alignment with the sidebar. */
|
||||
.main-wrapper {
|
||||
margin-left: var(--sidebar-width);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
|
@ -398,6 +412,23 @@ body {
|
|||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* #238: a rotated display shown as its viewer sees it. The stage is the panel's face; the frame is
|
||||
its framebuffer, turned back by the mount (js/lib/device-frame.js sizes and rotates it from the
|
||||
players' shared rule). The frame is centred on its offset parent, so a stage without
|
||||
`position: relative` would centre it on the page — hence both halves live here together. */
|
||||
.display-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.display-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.device-card-preview {
|
||||
aspect-ratio: 16/9;
|
||||
background: var(--bg-primary);
|
||||
|
|
@ -1487,7 +1518,8 @@ body {
|
|||
}
|
||||
.sidebar-backdrop.open { display: block; }
|
||||
.nav-link { min-height: 44px; padding: 10px 14px; }
|
||||
.content { margin-left: 0; padding: 16px; padding-top: 68px; }
|
||||
.main-wrapper { margin-left: 0; }
|
||||
.content { padding: 16px; padding-top: 68px; }
|
||||
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||
.device-grid { grid-template-columns: 1fr; }
|
||||
.content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||||
|
|
|
|||
|
|
@ -81,7 +81,13 @@
|
|||
|
||||
<h2>Step 2: Run the ScreenTinker installer</h2>
|
||||
<p>Open a terminal on the Pi and run:</p>
|
||||
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | bash</code></pre>
|
||||
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash</code></pre>
|
||||
<p>The <code>sudo</code> is required — the installer writes systemd units, installs packages and
|
||||
configures autostart. Without it the script stops on its first line and prints the correct
|
||||
command, so nothing is half-installed.</p>
|
||||
<p>That gives you <strong>All-in-One</strong>: the server and the player on the same Pi. To use
|
||||
the Pi as a player only, pointing at a ScreenTinker server you already run:</p>
|
||||
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://your-server</code></pre>
|
||||
<p>The script will:</p>
|
||||
<ul>
|
||||
<li>Install Chromium (the kiosk browser used as the player)</li>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@
|
|||
<link rel="stylesheet" href="/css/reset.css">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<!-- #238: the players' own rotation rule (server/lib/orientation-style.js), served under /player
|
||||
because it IS the player's — the dashboard previews of rotated screens went sideways for as
|
||||
long as this side derived its own geometry. Classic script: it publishes window.OrientationStyle
|
||||
for both the player and the ES-module dashboard. -->
|
||||
<script src="/player/orientation-style.js"></script>
|
||||
<!-- OAuth providers loaded on-demand by login.js when needed -->
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -169,9 +174,13 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="content" id="app">
|
||||
<!-- Views rendered here -->
|
||||
</main>
|
||||
<div class="main-wrapper">
|
||||
<div id="banners"></div>
|
||||
|
||||
<main class="content" id="app">
|
||||
<!-- Views rendered here -->
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<div class="modal-overlay" id="addDeviceModal" style="display:none">
|
||||
|
|
|
|||
|
|
@ -210,6 +210,12 @@ export const api = {
|
|||
|
||||
// TOTP 2FA (#100) — opt-in per-user, local accounts only. See routes/auth.js.
|
||||
totpStatus: () => request('/auth/totp/status'),
|
||||
// Unlink an instance-wide SSO provider. The new password is required in the same call:
|
||||
// the account must never sit between credentials.
|
||||
ssoUnlink: (password) => request('/auth/oidc/unlink', { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
// Returns { url } to navigate to. Fetched rather than navigated to, because the session is
|
||||
// a bearer token and a top-level navigation cannot carry one.
|
||||
ssoLinkStart: (slug) => request(`/auth/oidc/${encodeURIComponent(slug)}/link/start`),
|
||||
totpSetup: () => request('/auth/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => request('/auth/totp/enable', { method: 'POST', body: JSON.stringify({ code }) }),
|
||||
totpDisable: (code) => request('/auth/totp/disable', { method: 'POST', body: JSON.stringify({ code }) }),
|
||||
|
|
@ -223,6 +229,7 @@ export const api = {
|
|||
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
|
||||
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
updateWorkspaceSecuritySettings: (workspaceId, data) => request(`/workspaces/${workspaceId}/security-settings`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
||||
// Workspace members + invites (slice 2A read-only)
|
||||
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
|
||||
|
|
@ -259,6 +266,10 @@ export const api = {
|
|||
// #146: toggle the /api/status debug block exposure (platform-admin only).
|
||||
adminGetStatusDebug: () => request('/admin/status-debug'),
|
||||
adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
||||
// Opt-in install statistics. GET returns { state, payload, last_report } — payload is the exact
|
||||
// body that would be sent, so the UI can show it rather than describe it.
|
||||
adminGetTelemetry: () => request('/admin/telemetry'),
|
||||
adminSetTelemetry: (enabled) => request('/admin/telemetry', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
||||
|
||||
// Per-user workspace membership management (platform Users page modal).
|
||||
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { isPlatformAdmin } from './utils.js';
|
|||
import { renderWorkspaceSwitcher } from './components/workspace-switcher.js';
|
||||
import { showToast } from './components/toast.js';
|
||||
import { api } from './api.js';
|
||||
import { esc } from './utils.js';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
|
@ -248,7 +249,7 @@ async function refreshCurrentUser() {
|
|||
// a redirect loop.
|
||||
const hash = window.location.hash || '#/';
|
||||
if (hasNoAccessibleWorkspace(fresh)
|
||||
&& hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') {
|
||||
&& hash !== '#/no-workspace' && !hash.startsWith('#/login') && hash !== '#/change-password') {
|
||||
window.location.hash = '#/no-workspace';
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -338,14 +339,29 @@ function route() {
|
|||
// do nothing. The login view reads the token off the hash and shows the new-password form.
|
||||
const isResetRoute = hash.startsWith('#/reset-password');
|
||||
|
||||
/*
|
||||
* ⚠️ The SAME rule the comment above states, for the login route.
|
||||
*
|
||||
* The server finishes every single sign-on by redirecting to `#/login?sso=1` (claim the session)
|
||||
* or `#/login?sso_error=<code>` (say what went wrong). Matching the hash EXACTLY meant neither
|
||||
* survived: an unauthenticated browser — the only kind that arrives here — had the hash rewritten
|
||||
* to a bare `#/login` and the query was gone before the login view ever ran. So a user who
|
||||
* authenticated perfectly at their identity provider landed back on a clean login page, still
|
||||
* signed out, with no message; and all sixteen error codes rendered SILENCE, which is worse than
|
||||
* a wrong message because there is nothing to report or search for.
|
||||
*
|
||||
* It took the pre-existing `?verified=1` email-verification toast with it.
|
||||
*/
|
||||
const isLoginRoute = hash === '#/login' || hash.startsWith('#/login?');
|
||||
|
||||
// Auth check - redirect to login if not authenticated
|
||||
if (!isAuthenticated() && hash !== '#/login' && !isResetRoute) {
|
||||
if (!isAuthenticated() && !isLoginRoute && !isResetRoute) {
|
||||
window.location.hash = '#/login';
|
||||
return;
|
||||
}
|
||||
|
||||
// If authenticated and on login page, redirect to dashboard or onboarding
|
||||
if (isAuthenticated() && (hash === '#/login' || isResetRoute)) {
|
||||
if (isAuthenticated() && (isLoginRoute || isResetRoute)) {
|
||||
window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding';
|
||||
return;
|
||||
}
|
||||
|
|
@ -422,8 +438,10 @@ function route() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Login page (and password-reset links from email) - hide sidebar
|
||||
if (hash === '#/login' || isResetRoute) {
|
||||
// Login page (and password-reset links from email) - hide sidebar.
|
||||
// Matches `#/login?...` too: the single sign-on return carries `?sso=1` / `?sso_error=<code>`,
|
||||
// and an exact comparison meant the login view was never rendered for either.
|
||||
if (isLoginRoute || isResetRoute) {
|
||||
sidebar.style.display = 'none';
|
||||
app.style.marginLeft = '0';
|
||||
const mb = document.getElementById('mobileMenuBtn');
|
||||
|
|
@ -556,6 +574,7 @@ function updateSidebarUser() {
|
|||
const user = getCurrentUser();
|
||||
if (!user) return;
|
||||
updateVerifyBanner(user);
|
||||
updateWidgetSandboxWarningBanner(user);
|
||||
|
||||
// Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin')
|
||||
const adminNav = document.getElementById('adminNavItem');
|
||||
|
|
@ -577,9 +596,9 @@ function updateSidebarUser() {
|
|||
|
||||
userEl.innerHTML = `
|
||||
${user.avatar_url ? `<img src="${user.avatar_url}" style="width:28px;height:28px;border-radius:50%">` :
|
||||
`<div style="width:28px;height:28px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:white">${(user.name || user.email)[0].toUpperCase()}</div>`}
|
||||
`<div style="width:28px;height:28px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:white">${esc((user.name || user.email)[0].toUpperCase())}</div>`}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div>
|
||||
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(user.name || user.email)}</div>
|
||||
<div style="font-size:10px;color:var(--text-muted)">${user.role}</div>
|
||||
</div>
|
||||
<button id="logoutBtn" class="btn-icon" title="${t('auth.sign_out')}" style="flex-shrink:0">
|
||||
|
|
@ -608,8 +627,8 @@ function updateVerifyBanner(user) {
|
|||
const unverified = user && user.email_verified === 0 && user.auth_provider === 'local';
|
||||
if (!unverified) { if (existing) existing.remove(); return; }
|
||||
if (existing) return;
|
||||
const appEl = document.getElementById('app');
|
||||
if (!appEl || !appEl.parentNode) return;
|
||||
const bannersEl = document.getElementById('banners');
|
||||
if (!bannersEl) return;
|
||||
const b = document.createElement('div');
|
||||
b.id = 'verifyBanner';
|
||||
b.style.cssText = 'background:var(--warning,#f59e0b);color:#1a1200;padding:9px 16px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap';
|
||||
|
|
@ -623,7 +642,29 @@ function updateVerifyBanner(user) {
|
|||
catch { showToast(t('auth.verify_resend_failed'), 'error'); }
|
||||
});
|
||||
b.appendChild(btn);
|
||||
appEl.parentNode.insertBefore(b, appEl);
|
||||
bannersEl.appendChild(b);
|
||||
}
|
||||
|
||||
function updateWidgetSandboxWarningBanner(user) {
|
||||
const existing = document.getElementById('widgetSandboxWarningBanner');
|
||||
const disabled = !!user?.current_organization?.widget_sandbox_isolation_disabled;
|
||||
if (!disabled) { if (existing) existing.remove(); return; }
|
||||
if (existing) return;
|
||||
const bannersEl = document.getElementById('banners');
|
||||
if (!bannersEl) return;
|
||||
const b = document.createElement('div');
|
||||
b.id = 'widgetSandboxWarningBanner';
|
||||
b.style.cssText = 'background:var(--danger,#dc2626);color:#fff;padding:10px 16px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;font-weight:600';
|
||||
const text = document.createElement('span');
|
||||
text.style.whiteSpace = 'pre-line';
|
||||
text.textContent = 'Widget sandbox isolation is DISABLED. Widget code in this organization runs\nwith full access to user sessions. Re-enable in Settings > Security.';
|
||||
const link = document.createElement('a');
|
||||
link.href = '#/settings';
|
||||
link.textContent = 'Open Settings';
|
||||
link.style.cssText = 'color:#fff;text-decoration:underline;font-weight:700';
|
||||
b.appendChild(text);
|
||||
b.appendChild(link);
|
||||
bannersEl.appendChild(b);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
/*
|
||||
* ⚠️ Messages are ESCAPED. This builds innerHTML, and callers pass server error strings straight
|
||||
* in — including ones that reflect user input verbatim, such as the OIDC issuer in
|
||||
* `not a URL: <value>`. A review typed `<img src=x onerror=alert(1)>` as an issuer and got script
|
||||
* execution in the admin's own session. A toast is a place text goes, never markup.
|
||||
*/
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function showToast(message, type = 'info', duration = 4000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
|
|
@ -10,7 +22,7 @@ export function showToast(message, type = 'info', duration = 4000) {
|
|||
type === 'error' ? '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>' :
|
||||
'<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>'}
|
||||
</svg>
|
||||
<span>${message}</span>
|
||||
<span>${esc(message)}</span>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ export function openTypeToConfirmModal(opts = {}) {
|
|||
const input = overlay.querySelector('#ttcInput');
|
||||
const confirmBtn = overlay.querySelector('#ttcConfirm');
|
||||
const errorEl = overlay.querySelector('#ttcError');
|
||||
input.focus();
|
||||
|
||||
const matches = () => input.value.trim() === String(expected);
|
||||
input.addEventListener('input', () => { confirmBtn.disabled = !matches(); });
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export default {
|
|||
'device.info.status': 'Status',
|
||||
'device.info.ip_address': 'IP-Adresse',
|
||||
'device.info.local_ip': 'Lokale IP',
|
||||
'device.info.local_ip6': 'Lokale IPv6',
|
||||
'device.info.wifi_needs_location': 'Standortberechtigung erforderlich',
|
||||
'device.info.battery': 'Akku',
|
||||
'device.info.storage': 'Speicher',
|
||||
|
|
@ -347,6 +348,9 @@ export default {
|
|||
'device.assign.select_first': 'Erst etwas auswählen',
|
||||
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
|
||||
'device.toast.screenshot_requested': 'Screenshot angefordert',
|
||||
'device.toast.screenshot_unsupported': 'Der Player dieses Displays unterstützt keine Screenshots',
|
||||
'device.toast.screenshot_offline': 'Display ist offline — Screenshot nicht angefordert',
|
||||
'device.toast.screenshot_failed': 'Screenshot-Anfrage fehlgeschlagen — keine Antwort vom Server',
|
||||
'device.toast.renamed': 'Bildschirm umbenannt',
|
||||
'device.toast.removing': 'Wird entfernt...',
|
||||
'device.toast.removed': 'Bildschirm entfernt',
|
||||
|
|
@ -763,6 +767,10 @@ export default {
|
|||
'playlist.draft.discard_changes': 'Änderungen verwerfen',
|
||||
'playlist.draft.publish': 'Veröffentlichen',
|
||||
'playlist.draft.publishing': 'Wird veröffentlicht...',
|
||||
'playlist.preview_prev': 'Zurück',
|
||||
'playlist.preview_next': 'Weiter',
|
||||
'playlist.preview_position': '{current} von {total}',
|
||||
'playlist.preview_zoned': 'Zonen laufen gleichzeitig',
|
||||
'playlist.toast.created': 'Playlist erstellt',
|
||||
'playlist.toast.deleted': 'Playlist gelöscht',
|
||||
'playlist.toast.published': 'Playlist veröffentlicht — Geräte aktualisiert',
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ export default {
|
|||
'common.unknown': 'Unknown',
|
||||
|
||||
// Auth (login view)
|
||||
'auth.next': 'Next',
|
||||
'auth.error_email_required': 'Enter your email address',
|
||||
'auth.sign_in': 'Sign In',
|
||||
'auth.sign_out': 'Sign out',
|
||||
'auth.create_account': 'Create Account',
|
||||
|
|
@ -132,6 +134,93 @@ export default {
|
|||
'auth.trial_notice': 'New accounts get a 14-day free Pro trial',
|
||||
'auth.divider_or': 'OR',
|
||||
'auth.signin_google': 'Sign in with Google',
|
||||
'auth.signin_with': 'Continue with {provider}',
|
||||
'auth.sso_failed': 'Single sign-on failed. Please try again.',
|
||||
'auth.sso_org_hint': 'Your organization uses single sign-on.',
|
||||
'auth.signin_sso': 'Continue with single sign-on',
|
||||
'sso.title': 'Single sign-on',
|
||||
'sso.blurb': 'Let your team sign in with your own identity provider. Anyone using an email address at one of your domains will be sent there instead of being asked for a password.',
|
||||
'sso.add': 'Add a provider',
|
||||
'sso.none': 'No provider configured yet.',
|
||||
'sso.create': 'Add provider',
|
||||
'sso.saved': 'Saved',
|
||||
'sso.save_failed': 'Could not save that provider.',
|
||||
'sso.load_failed': 'Could not load single sign-on settings.',
|
||||
'sso.missing_fields': 'Name, issuer and client ID are required.',
|
||||
'sso.confirm_delete': 'Remove this provider? Anyone who signs in with it will lose that route.',
|
||||
'sso.delete': 'Remove',
|
||||
'sso.enable': 'Enable',
|
||||
'sso.disable': 'Disable',
|
||||
'sso.disabled': 'disabled',
|
||||
'sso.domains_label': 'Email domains',
|
||||
'sso.domains_heading': 'Sign-in domains',
|
||||
'sso.removed': 'Removed.',
|
||||
'sso.only_stranded': 'Single sign-on is now required.\n\nThese members are not at a verified domain, so they can no longer sign in at all:\n\n{list}\n\nVerify their domain, or remove them from this organization.',
|
||||
'sso.only_heading': 'Require single sign-on',
|
||||
'sso.only_help': 'When required, people at your verified domains can only sign in through your identity provider — a password will not work. Your provider keeps control of MFA and of removing access.',
|
||||
'sso.only_on': 'Single sign-on is required for your verified domains.',
|
||||
'sso.only_off': 'Password sign-in is still allowed alongside single sign-on.',
|
||||
'sso.only_enable': 'Require single sign-on',
|
||||
'sso.only_confirm': 'Require single sign-on for everyone at your verified domains?\n\nPasswords will stop working for them at their next sign-in; sessions already open continue until they expire. Turning this back off needs approval from the people who run this server, so make sure your identity provider is working first.',
|
||||
'sso.only_needs_domain': 'Verify a sign-in domain first — otherwise nobody would be able to sign in.',
|
||||
'sso.only_remove_help': 'Turning this off re-opens password sign-in, so it needs approval from the people who run this server.',
|
||||
'sso.only_request': 'Request to stop requiring single sign-on',
|
||||
'sso.only_reason_prompt': 'Why do you need password sign-in re-opened? (optional, but it helps the reviewer)',
|
||||
'sso.only_requested': 'Request sent. Single sign-on stays required until it is approved.',
|
||||
'sso.only_pending': 'A request to stop requiring single sign-on is awaiting approval. Nothing changes until then.',
|
||||
'sso.only_cancel': 'Withdraw request',
|
||||
'sso.only_cancelled': 'Request withdrawn.',
|
||||
'sso.only_failed': 'That did not work.',
|
||||
'sso.domain_verified': 'verified',
|
||||
'sso.domain_pending': 'not verified — routes nobody yet',
|
||||
'sso.unverified_warning': 'Some domains are not verified yet, so nobody is routed to this provider by email address.',
|
||||
'sso.verify_now': 'Verify',
|
||||
'sso.verifying': 'Checking DNS…',
|
||||
'sso.verify_failed': 'Could not verify that domain.',
|
||||
'sso.domain_verified_toast': '{domain} is verified.',
|
||||
'sso.dns_instructions': 'Publish this TXT record in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.',
|
||||
'sso.callback_label': 'Redirect URI — add this to your provider',
|
||||
'sso.f_name': 'Display name',
|
||||
'sso.f_issuer': 'Issuer URL',
|
||||
'sso.f_issuer_hint': 'The base URL whose /.well-known/openid-configuration describes your provider. We check it before saving.',
|
||||
'sso.f_client_id': 'Client ID',
|
||||
'sso.f_client_secret': 'Client secret (optional)',
|
||||
'sso.f_client_secret_hint': 'Leave blank for a public client — we use PKCE, so a secret is not required. Stored encrypted and never shown again.',
|
||||
'sso.f_domains': 'Email domains',
|
||||
'sso.f_domains_hint': 'Comma separated. Anyone with an address at these domains is sent to this provider.',
|
||||
'sso.edit': 'Edit',
|
||||
'sso.save': 'Save changes',
|
||||
'sso.cancel': 'Cancel',
|
||||
'sso.secret_set': 'A secret is set — leave blank to keep it',
|
||||
'sso.secret_none': 'No secret set (public client)',
|
||||
'sso.secret_edit_hint': 'Leave blank to keep the current secret. Type a new one to replace it.',
|
||||
'sso.secret_clear': 'Remove the stored secret (use a public client)',
|
||||
'sso.test': 'Test',
|
||||
'sso.testing': 'Checking the provider…',
|
||||
'sso.test_failed': 'Could not reach that provider.',
|
||||
'sso.check_discovery': 'OpenID configuration',
|
||||
'sso.check_endpoints': 'Authorization and token endpoints',
|
||||
'sso.check_signing_keys': 'Signing keys',
|
||||
'sso.test_caveat': 'This confirms the provider is reachable and its tokens can be verified. It cannot check the client ID, the secret, or that the redirect URI is registered — only a real sign-in does that.',
|
||||
'auth.sso_err_expired': 'That sign-in took too long. Please try again.',
|
||||
'auth.sso_err_bad_state': 'Sign-in could not be verified. Please start again.',
|
||||
'auth.sso_err_no_code': 'The provider did not return an authorization code.',
|
||||
'auth.sso_err_no_email': 'Your provider did not share an email address.',
|
||||
'auth.sso_err_email_unverified': 'Your provider has not verified that email address.',
|
||||
'auth.sso_err_verification_failed': 'We could not verify the sign-in with your provider.',
|
||||
'auth.sso_err_provider_refused': 'Your provider declined the sign-in.',
|
||||
'auth.sso_err_provider_unavailable': 'That provider is not reachable right now.',
|
||||
'auth.sso_err_unknown_provider': 'That sign-in provider is not configured.',
|
||||
'auth.sso_err_registration_disabled': 'New accounts are disabled on this instance.',
|
||||
'auth.sso_err_account_exists_local': 'An account with this email already exists and uses a password. Sign in with your password instead.',
|
||||
'auth.sso_err_subject_mismatch': 'This email is already linked to a different account at your provider.',
|
||||
'auth.sso_err_server_error': 'Something went wrong completing sign-in.',
|
||||
// Both of these used to fall through to "please try again", which is advice that can never work:
|
||||
// retrying is exactly what will not help, and the user needs to be told who to talk to instead.
|
||||
'auth.sso_required': 'Your organization requires single sign-on. Use \u201cContinue with single sign-on\u201d above \u2014 your password will not work here.',
|
||||
'auth.sso_err_sso_required': 'Your organization requires its own single sign-on. Use the single sign-on option for your organization.',
|
||||
'auth.sso_err_domain_not_allowed': 'Your organization has not verified that email domain for sign-in. Ask your administrator to verify it in ScreenTinker.',
|
||||
'auth.sso_err_account_exists_other_provider': 'An account with this email already exists and signs in through a different provider. Use that provider, or ask your administrator.',
|
||||
'auth.signin_microsoft': 'Sign in with Microsoft',
|
||||
'auth.back_to_signin': 'Back to Sign In',
|
||||
// TOTP 2FA challenge (second login step)
|
||||
|
|
@ -403,6 +492,9 @@ export default {
|
|||
'device.confirm_discard_draft': 'Discard all unpublished changes and revert to the last published version?',
|
||||
'device.failed_load': 'Failed to load device',
|
||||
'device.no_screenshot': 'No screenshot available. Click "Screenshot" to capture one.',
|
||||
// Shown instead of the line above on a player that cannot capture its own screen — pointing at
|
||||
// a "Screenshot" button that is correctly not rendered reads as a broken dashboard.
|
||||
'device.no_screenshot_unsupported': 'This player cannot capture its own screen.',
|
||||
'device.no_content_assigned': 'No content assigned',
|
||||
'device.now_playing_id': 'Playing: {id}',
|
||||
'device.playlist_count_one': '1 item in playlist',
|
||||
|
|
@ -473,6 +565,7 @@ export default {
|
|||
'device.info.status': 'Status',
|
||||
'device.info.ip_address': 'IP Address',
|
||||
'device.info.local_ip': 'Local IP',
|
||||
'device.info.local_ip6': 'Local IPv6',
|
||||
'device.info.wifi_needs_location': 'Needs location permission',
|
||||
'device.info.battery': 'Battery',
|
||||
'device.info.storage': 'Storage',
|
||||
|
|
@ -484,6 +577,8 @@ export default {
|
|||
'device.info.os_version': 'OS Version',
|
||||
'device.info.serial': 'Serial',
|
||||
'device.info.temperature': 'Temperature',
|
||||
'device.info.attached_display': 'Attached display',
|
||||
'device.info.video_mode': 'Video mode',
|
||||
'device.info.output_n': '(output {n})',
|
||||
'device.info.player_type': 'Player Type',
|
||||
'device.info.web_player': 'Web Player',
|
||||
|
|
@ -545,7 +640,16 @@ export default {
|
|||
'device.form.notes_label': 'Notes',
|
||||
'device.form.notes_placeholder': 'Location, setup details, etc.',
|
||||
'device.debug.toggle': 'Debug logging (live)',
|
||||
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
|
||||
'device.debug.hint': 'Streams this display\'s log in real time, and replays what it buffered before you opened it. Turns off when you leave this screen, and on the device itself after 30 minutes.',
|
||||
'device.debug.freeze': 'Freeze',
|
||||
'device.debug.resume': 'Resume',
|
||||
'device.debug.copy': 'Copy',
|
||||
'device.debug.clear': 'Clear',
|
||||
'device.debug.held': 'frozen — {n} new line(s) waiting',
|
||||
'device.debug.held_max': 'frozen — {n} waiting (oldest now being dropped)',
|
||||
'device.debug.copied': 'Copied {n} line(s) to the clipboard',
|
||||
'device.debug.copy_empty': 'Nothing to copy yet',
|
||||
'device.debug.copy_failed': 'Could not reach the clipboard — select the log and copy manually',
|
||||
'device.ota.toggle': 'Self-update (OTA)',
|
||||
'device.ota.beta': 'Accept pre-release builds',
|
||||
'device.ota.beta_hint': 'Puts this display on the pre-release channel: it receives the beta build if the server has one published, and keeps a test build instead of being updated back to the current release. Untick to move it back to the release build. Does nothing if no beta is published.',
|
||||
|
|
@ -579,6 +683,8 @@ export default {
|
|||
'device.ctl.screen_on': 'Screen On',
|
||||
'device.ctl.launch_player': 'Launch Player',
|
||||
'device.ctl.force_update': 'Force Update',
|
||||
'device.ctl.clear_update_cache': 'Clear Update Cache',
|
||||
'device.ctl.clear_update_cache_tip': 'Delete any update file this display has already downloaded, so the next check fetches a fresh copy. Use if updates keep failing.',
|
||||
'device.ctl.shutdown': 'Shutdown',
|
||||
// Remote tab
|
||||
'device.remote.start_prompt': 'Click "Start Remote" to begin',
|
||||
|
|
@ -645,6 +751,9 @@ export default {
|
|||
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
|
||||
// Toasts
|
||||
'device.toast.screenshot_requested': 'Screenshot requested',
|
||||
'device.toast.screenshot_unsupported': 'This display\'s player can\'t take screenshots',
|
||||
'device.toast.screenshot_offline': 'Display is offline — screenshot not requested',
|
||||
'device.toast.screenshot_failed': 'Screenshot request failed — no response from server',
|
||||
'device.toast.renamed': 'Display renamed',
|
||||
'device.toast.removing': 'Removing...',
|
||||
'device.toast.removed': 'Display removed',
|
||||
|
|
@ -666,6 +775,7 @@ export default {
|
|||
'device.toast.screen_on_sent': 'Screen on command sent',
|
||||
'device.toast.launch_sent': 'Launch command sent',
|
||||
'device.toast.update_triggered': 'Update check triggered',
|
||||
'device.toast.update_cache_cleared': 'Update cache cleared — the next check will download afresh',
|
||||
'device.toast.remote_started': 'Remote session started',
|
||||
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
|
||||
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
|
||||
|
|
@ -731,6 +841,33 @@ export default {
|
|||
'settings.save_profile': 'Save Profile',
|
||||
'settings.email_alerts': 'Email me when devices go offline',
|
||||
'settings.change_password': 'Change Password',
|
||||
// Sign-in method (#258). The link warning is deliberately explicit about destruction of the
|
||||
// password — that is the part users miss, and it is not reversible without setting a new one.
|
||||
'settings.signin_method': 'Sign-in method',
|
||||
'settings.signin_password_now': 'This account signs in with a password. You can link it to a single sign-on provider instead.',
|
||||
'settings.signin_password_only': 'This account signs in with a password. No single sign-on providers are configured on this server.',
|
||||
'settings.signin_link': 'Link {provider}',
|
||||
'settings.signin_link_warning': 'You are linking this account to {provider}.\n\nYour local password will be DELETED. After this you sign in with {provider} only.\n\nTo go back to a password later, unlink {provider} and set a new one.',
|
||||
'settings.signin_linked': 'This account signs in with {provider}. It has no password.',
|
||||
'settings.signin_unlink': 'Unlink {provider}',
|
||||
'settings.signin_unlink_desc': 'Set a password to sign in with instead. It takes effect immediately and {provider} is unlinked in the same step.',
|
||||
'settings.signin_unlink_confirm': 'Set password and unlink',
|
||||
'settings.signin_unlinked_toast': 'Unlinked. You now sign in with your password.',
|
||||
'settings.passwords_dont_match': 'The two passwords do not match',
|
||||
'settings.signin_linked_toast': 'Linked. You now sign in with {provider}, and your password has been removed.',
|
||||
'settings.signin_err_link_email_mismatch': 'That provider account uses a different email address than this account. Sign in to the provider with the same address and try again.',
|
||||
'settings.signin_err_link_already_used': 'That provider account is already linked to a different ScreenTinker account.',
|
||||
'settings.signin_err_not_linkable': 'Only the providers configured on this server can be linked to an account.',
|
||||
'settings.signin_err_no_email': 'The provider did not supply an email address, so the account could not be linked.',
|
||||
'settings.signin_err_email_unverified': 'The provider would not confirm that email address is verified.',
|
||||
'settings.signin_err_verification_failed': 'The sign-in could not be verified. Nothing was changed.',
|
||||
'settings.signin_err_provider_unavailable': 'The provider could not be reached. Nothing was changed.',
|
||||
'settings.signin_err_provider_refused': 'The provider refused the request. Nothing was changed.',
|
||||
'settings.signin_err_unknown_provider': 'That provider is not configured on this server.',
|
||||
'settings.signin_err_expired': 'That took too long. Start the link again.',
|
||||
'settings.signin_err_bad_state': 'The response did not match the request. Start the link again.',
|
||||
'settings.signin_err_no_code': 'The provider returned no authorization code. Start the link again.',
|
||||
'settings.signin_err_server_error': 'Something went wrong. Nothing was changed.',
|
||||
'settings.password_min_8': 'Must be at least 8 characters.',
|
||||
'settings.current_password': 'Current Password',
|
||||
'settings.new_password': 'New Password',
|
||||
|
|
@ -1159,6 +1296,10 @@ export default {
|
|||
'playlist.zones_count_one': '1 zone',
|
||||
'playlist.zones_count_other': '{n} zones',
|
||||
'playlist.layout_ambiguous': 'Items reference zones from more than one layout',
|
||||
'playlist.preview_prev': 'Previous',
|
||||
'playlist.preview_next': 'Next',
|
||||
'playlist.preview_position': '{current} of {total}',
|
||||
'playlist.preview_zoned': 'Zones play together',
|
||||
'playlist.back': 'Back',
|
||||
'playlist.items_empty': 'This playlist is empty',
|
||||
'playlist.items_empty_hint': 'Click "Add Content" to add items.',
|
||||
|
|
@ -1306,6 +1447,16 @@ export default {
|
|||
'admin.orgs.ws_deleted': 'Workspace "{name}" deleted',
|
||||
'admin.access_denied': 'Access Denied',
|
||||
'admin.access_denied_desc': 'Platform admin access required.',
|
||||
'admin.sso_only.title': 'Single sign-on removal requests',
|
||||
'admin.sso_only.desc': 'An organization has asked to stop requiring its identity provider. Until you approve, nothing changes for them.',
|
||||
'admin.sso_only.requested_by': 'Requested by {who}',
|
||||
'admin.sso_only.effect': 'Approving re-opens password sign-in for everyone at this organization\u2019s verified domains.',
|
||||
'admin.sso_only.approve': 'Approve removal',
|
||||
'admin.sso_only.reject': 'Reject',
|
||||
'admin.sso_only.confirm': 'Re-open password sign-in for this organization?\n\nTheir identity provider will no longer be the only way in. Approve only if you are satisfied the request is genuine.',
|
||||
'admin.sso_only.approved': 'Approved. Password sign-in is re-opened for that organization.',
|
||||
'admin.sso_only.rejected': 'Rejected. Single sign-on is still required.',
|
||||
'admin.sso_only.failed': 'That did not work.',
|
||||
'admin.all_users': 'All Users',
|
||||
'admin.plans': 'Subscription Plans',
|
||||
'admin.col.accounts': 'Accounts',
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ export default {
|
|||
'device.info.status': 'Estado',
|
||||
'device.info.ip_address': 'Dirección IP',
|
||||
'device.info.local_ip': 'IP local',
|
||||
'device.info.local_ip6': 'IPv6 local',
|
||||
'device.info.wifi_needs_location': 'Requiere permiso de ubicación',
|
||||
'device.info.battery': 'Batería',
|
||||
'device.info.storage': 'Almacenamiento',
|
||||
|
|
@ -377,6 +378,9 @@ export default {
|
|||
'device.assign.select_first': 'Primero selecciona algo',
|
||||
'device.assign.kiosk_widget_name': 'Kiosco: {name}',
|
||||
'device.toast.screenshot_requested': 'Captura solicitada',
|
||||
'device.toast.screenshot_unsupported': 'El reproductor de esta pantalla no admite capturas de pantalla',
|
||||
'device.toast.screenshot_offline': 'La pantalla está desconectada — captura no solicitada',
|
||||
'device.toast.screenshot_failed': 'La solicitud de captura falló — sin respuesta del servidor',
|
||||
'device.toast.renamed': 'Pantalla renombrada',
|
||||
'device.toast.removing': 'Eliminando...',
|
||||
'device.toast.removed': 'Pantalla eliminada',
|
||||
|
|
@ -793,6 +797,10 @@ export default {
|
|||
'playlist.draft.discard_changes': 'Descartar cambios',
|
||||
'playlist.draft.publish': 'Publicar',
|
||||
'playlist.draft.publishing': 'Publicando...',
|
||||
'playlist.preview_prev': 'Anterior',
|
||||
'playlist.preview_next': 'Siguiente',
|
||||
'playlist.preview_position': '{current} de {total}',
|
||||
'playlist.preview_zoned': 'Las zonas se reproducen juntas',
|
||||
'playlist.toast.created': 'Lista creada',
|
||||
'playlist.toast.deleted': 'Lista eliminada',
|
||||
'playlist.toast.published': 'Lista publicada — dispositivos actualizados',
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export default {
|
|||
'device.info.status': 'Statut',
|
||||
'device.info.ip_address': 'Adresse IP',
|
||||
'device.info.local_ip': 'IP locale',
|
||||
'device.info.local_ip6': 'IPv6 locale',
|
||||
'device.info.wifi_needs_location': 'Autorisation de localisation requise',
|
||||
'device.info.battery': 'Batterie',
|
||||
'device.info.storage': 'Stockage',
|
||||
|
|
@ -347,6 +348,9 @@ export default {
|
|||
'device.assign.select_first': 'Sélectionnez d\'abord un élément',
|
||||
'device.assign.kiosk_widget_name': 'Kiosque : {name}',
|
||||
'device.toast.screenshot_requested': 'Capture demandée',
|
||||
'device.toast.screenshot_unsupported': 'Le lecteur de cet écran ne prend pas en charge les captures d\'écran',
|
||||
'device.toast.screenshot_offline': 'Écran hors ligne — capture non demandée',
|
||||
'device.toast.screenshot_failed': 'Échec de la demande de capture — pas de réponse du serveur',
|
||||
'device.toast.renamed': 'Écran renommé',
|
||||
'device.toast.removing': 'Suppression...',
|
||||
'device.toast.removed': 'Écran retiré',
|
||||
|
|
@ -763,6 +767,10 @@ export default {
|
|||
'playlist.draft.discard_changes': 'Annuler les modifications',
|
||||
'playlist.draft.publish': 'Publier',
|
||||
'playlist.draft.publishing': 'Publication...',
|
||||
'playlist.preview_prev': 'Précédent',
|
||||
'playlist.preview_next': 'Suivant',
|
||||
'playlist.preview_position': '{current} sur {total}',
|
||||
'playlist.preview_zoned': 'Les zones jouent ensemble',
|
||||
'playlist.toast.created': 'Liste créée',
|
||||
'playlist.toast.deleted': 'Liste supprimée',
|
||||
'playlist.toast.published': 'Liste publiée — appareils mis à jour',
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ export default {
|
|||
'device.info.status': 'Stato',
|
||||
'device.info.ip_address': 'Indirizzo IP',
|
||||
'device.info.local_ip': 'IP locale',
|
||||
'device.info.local_ip6': 'IPv6 locale',
|
||||
'device.info.wifi_needs_location': 'Richiede permesso di posizione',
|
||||
'device.info.battery': 'Batteria',
|
||||
'device.info.storage': 'Archiviazione',
|
||||
|
|
@ -371,6 +372,9 @@ export default {
|
|||
'device.assign.kiosk_widget_name': 'Chiosco: {name}',
|
||||
// Toasts
|
||||
'device.toast.screenshot_requested': 'Richiesta screenshot inviata',
|
||||
'device.toast.screenshot_unsupported': 'Il player di questo schermo non supporta gli screenshot',
|
||||
'device.toast.screenshot_offline': 'Lo schermo è offline — screenshot non richiesto',
|
||||
'device.toast.screenshot_failed': 'Richiesta screenshot non riuscita — nessuna risposta dal server',
|
||||
'device.toast.renamed': 'Schermo rinominato',
|
||||
'device.toast.removing': 'Rimozione in corso...',
|
||||
'device.toast.removed': 'Schermo rimosso',
|
||||
|
|
@ -751,6 +755,10 @@ export default {
|
|||
'playlist.draft.discard_changes': 'Scarta Modifiche',
|
||||
'playlist.draft.publish': 'Pubblica',
|
||||
'playlist.draft.publishing': 'Pubblicazione...',
|
||||
'playlist.preview_prev': 'Precedente',
|
||||
'playlist.preview_next': 'Successivo',
|
||||
'playlist.preview_position': '{current} di {total}',
|
||||
'playlist.preview_zoned': 'Le zone vengono riprodotte insieme',
|
||||
'playlist.toast.created': 'Playlist creata',
|
||||
'playlist.toast.deleted': 'Playlist eliminata',
|
||||
'playlist.toast.published': 'Playlist pubblicata — dispositivi aggiornati',
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export default {
|
|||
'device.info.status': 'Status',
|
||||
'device.info.ip_address': 'Endereço IP',
|
||||
'device.info.local_ip': 'IP local',
|
||||
'device.info.local_ip6': 'IPv6 local',
|
||||
'device.info.wifi_needs_location': 'Requer permissão de localização',
|
||||
'device.info.battery': 'Bateria',
|
||||
'device.info.storage': 'Armazenamento',
|
||||
|
|
@ -347,6 +348,9 @@ export default {
|
|||
'device.assign.select_first': 'Selecione algo primeiro',
|
||||
'device.assign.kiosk_widget_name': 'Quiosque: {name}',
|
||||
'device.toast.screenshot_requested': 'Captura solicitada',
|
||||
'device.toast.screenshot_unsupported': 'O player desta tela não suporta capturas de tela',
|
||||
'device.toast.screenshot_offline': 'A tela está offline — captura não solicitada',
|
||||
'device.toast.screenshot_failed': 'Falha na solicitação de captura — sem resposta do servidor',
|
||||
'device.toast.renamed': 'Tela renomeada',
|
||||
'device.toast.removing': 'Removendo...',
|
||||
'device.toast.removed': 'Tela removida',
|
||||
|
|
@ -763,6 +767,10 @@ export default {
|
|||
'playlist.draft.discard_changes': 'Descartar alterações',
|
||||
'playlist.draft.publish': 'Publicar',
|
||||
'playlist.draft.publishing': 'Publicando...',
|
||||
'playlist.preview_prev': 'Anterior',
|
||||
'playlist.preview_next': 'Próximo',
|
||||
'playlist.preview_position': '{current} de {total}',
|
||||
'playlist.preview_zoned': 'As zonas tocam juntas',
|
||||
'playlist.toast.created': 'Playlist criada',
|
||||
'playlist.toast.deleted': 'Playlist excluída',
|
||||
'playlist.toast.published': 'Playlist publicada — dispositivos atualizados',
|
||||
|
|
|
|||
76
frontend/js/lib/device-frame.js
Normal file
76
frontend/js/lib/device-frame.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// #238: show a device's output the way a person standing in front of the panel sees it.
|
||||
//
|
||||
// Everywhere the dashboard showed a rotated display — the device preview modal, the Now Playing
|
||||
// screenshot, the device cards — it showed the framebuffer as captured/rendered, i.e. sideways,
|
||||
// while the panel on the wall was right. Designers use these surfaces to check their work, so a
|
||||
// sideways preview turned every anomaly on a portrait screen into "is that real?".
|
||||
//
|
||||
// The geometry itself is NOT here: it is the same rule the players rotate by
|
||||
// (server/lib/orientation-style.js, loaded as window.OrientationStyle), because a second copy of a
|
||||
// rotation rule is precisely how the dashboard and the panel came to disagree in the first place.
|
||||
// This file only measures the box and applies the answer.
|
||||
|
||||
// stage element -> { inner, orientation }. Weak so a re-rendered dashboard doesn't pin dead nodes.
|
||||
const framed = new WeakMap();
|
||||
let observer = null;
|
||||
|
||||
// Sizes are in px, so they are wrong the moment the stage resizes — and a stage inside an inactive
|
||||
// tab measures 0x0 until it is shown, which is the common case for Now Playing. One observer for
|
||||
// every stage: the callback re-measures whatever actually changed, including 0 -> visible.
|
||||
function ensureObserver() {
|
||||
if (observer || typeof ResizeObserver === 'undefined') return observer;
|
||||
observer = new ResizeObserver((entries) => { entries.forEach(e => applyFrame(e.target)); });
|
||||
return observer;
|
||||
}
|
||||
|
||||
function applyFrame(stage) {
|
||||
const entry = framed.get(stage);
|
||||
if (!entry) return;
|
||||
if (!stage.isConnected) { // modal closed / list re-rendered
|
||||
framed.delete(stage);
|
||||
if (observer) observer.unobserve(stage);
|
||||
return;
|
||||
}
|
||||
const OS = typeof window !== 'undefined' && window.OrientationStyle;
|
||||
if (!OS || !OS.previewFrameStyle) return; // shared rule failed to load: leave today's rendering alone
|
||||
const st = OS.previewFrameStyle(entry.orientation, { width: stage.clientWidth, height: stage.clientHeight });
|
||||
const el = entry.inner;
|
||||
if (!el) return;
|
||||
el.style.width = st.width;
|
||||
el.style.height = st.height;
|
||||
el.style.top = st.top;
|
||||
el.style.left = st.left;
|
||||
el.style.transform = st.transform;
|
||||
el.style.transformOrigin = st.transformOrigin;
|
||||
// A rotated screenshot has to be letterboxed rather than cropped: the card's `object-fit: cover`
|
||||
// applied to a frame whose axes are swapped fills the box by discarding most of the picture —
|
||||
// a "preview" of the middle 30% of the screen.
|
||||
el.style.objectFit = (st.transform && OS.swapsAxes(entry.orientation)) ? 'contain' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Present `inner` (an iframe of the player, or a screenshot img) inside `stage` as the panel's
|
||||
* face. Safe to call repeatedly — screenshot handlers replace the img element, and re-registering
|
||||
* is how the new one gets framed.
|
||||
*
|
||||
* @param {Element} stage fixed box in the dashboard, sized for the AS-DISPLAYED aspect
|
||||
* @param {Element} inner the device's output; positioned and rotated inside the stage
|
||||
* @param {string} orientation the device row's orientation
|
||||
*/
|
||||
export function frameDeviceOutput(stage, inner, orientation) {
|
||||
if (!stage || !inner) return;
|
||||
// Applied here rather than left to each call site: the frame is absolutely positioned and centred
|
||||
// on its offset parent, so a stage that forgets `position: relative` centres it on the PAGE.
|
||||
stage.classList.add('display-stage');
|
||||
inner.classList.add('display-frame');
|
||||
framed.set(stage, { inner, orientation: orientation || 'landscape' });
|
||||
applyFrame(stage);
|
||||
const ro = ensureObserver();
|
||||
if (ro) { ro.unobserve(stage); ro.observe(stage); }
|
||||
}
|
||||
|
||||
/** Stage aspect for a device, as the viewer sees it ('9 / 16' for a portrait-hung 16:9 panel). */
|
||||
export function displayAspectRatio(orientation) {
|
||||
const OS = typeof window !== 'undefined' && window.OrientationStyle;
|
||||
return OS && OS.previewAspectRatio ? OS.previewAspectRatio(orientation) : '16 / 9';
|
||||
}
|
||||
|
|
@ -112,9 +112,22 @@ function emit(event, data) {
|
|||
if (cbs) cbs.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
export function requestScreenshot(deviceId) {
|
||||
// Optional callback receives the server-side ack: { delivered, reason, capability }.
|
||||
// reason is 'offline' (no live connection) or 'unsupported' (this player type can't
|
||||
// take screenshots). Callers without a callback keep firing-and-forgetting — the
|
||||
// dashboard grid and the device-detail 5s poll stay silent; only the explicit
|
||||
// Screenshot button asks for the verdict.
|
||||
export function requestScreenshot(deviceId, callback) {
|
||||
console.log('requestScreenshot:', deviceId, 'socket connected:', dashboardSocket?.connected);
|
||||
if (dashboardSocket) dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId });
|
||||
if (!dashboardSocket) return;
|
||||
if (typeof callback === 'function') {
|
||||
dashboardSocket.timeout(5000).emit('dashboard:request-screenshot', { device_id: deviceId }, (err, ack) => {
|
||||
if (err) callback({ delivered: false, reason: 'no_ack' });
|
||||
else callback(ack || { delivered: false, reason: 'no_ack' });
|
||||
});
|
||||
} else {
|
||||
dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId });
|
||||
}
|
||||
}
|
||||
|
||||
export function startRemote(deviceId) {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,15 @@ export async function render(container) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single sign-on removal approvals. First, because it is the only screen on this page an
|
||||
operator is DIRECTED to by an email, and because a tenant is locked out of their own
|
||||
product while it sits here. -->
|
||||
<div class="settings-section" id="ssoOnlySection" style="display:none">
|
||||
<h3>${t('admin.sso_only.title')}</h3>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.sso_only.desc')}</p>
|
||||
<div id="ssoOnlyRequests"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>${t('admin.all_users')}</h3>
|
||||
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
||||
|
|
@ -137,6 +146,7 @@ export async function render(container) {
|
|||
|
||||
loadUsers();
|
||||
loadOrgs();
|
||||
loadSsoOnlyRequests();
|
||||
loadBranding();
|
||||
loadPlans();
|
||||
loadSystem();
|
||||
|
|
@ -146,6 +156,70 @@ export async function render(container) {
|
|||
|
||||
// #36: list organizations with owner + resource counts; platform admin can
|
||||
// cascade-delete an org or an individual workspace (type-the-name confirm).
|
||||
/*
|
||||
* Pending "stop requiring single sign-on" requests.
|
||||
*
|
||||
* The notification email tells the operator to review this under Admin, and for a while it did not
|
||||
* exist — the only way to approve was curl, while the customer sat locked out. The section hides
|
||||
* itself when there is nothing pending so it is never noise.
|
||||
*/
|
||||
async function loadSsoOnlyRequests() {
|
||||
const section = document.getElementById('ssoOnlySection');
|
||||
const host = document.getElementById('ssoOnlyRequests');
|
||||
if (!section || !host) return;
|
||||
// NB: `api` is a map of named methods, not a generic client — there is no api.get(), and calling
|
||||
// one silently hid this whole section behind the catch below.
|
||||
const authed = (path, init = {}) => fetch(`/api${path}`, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...(init.headers || {}) },
|
||||
});
|
||||
|
||||
let requests = [];
|
||||
try {
|
||||
const res = await authed('/organizations/sso-only/removal-requests');
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
requests = (await res.json()).requests || [];
|
||||
} catch {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// Clear as well as hide: leaving the last decided request in the tree kept its live
|
||||
// Approve/Reject listeners attached to a request that no longer exists.
|
||||
if (!requests.length) { host.innerHTML = ''; section.style.display = 'none'; return; }
|
||||
section.style.display = '';
|
||||
|
||||
host.innerHTML = requests.map((r) => `
|
||||
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
|
||||
<div><strong>${esc(r.organization_name || r.organization_id)}</strong></div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">
|
||||
${esc(t('admin.sso_only.requested_by', { who: r.requested_by_email || 'unknown' }))}
|
||||
</div>
|
||||
${r.reason ? `<div style="font-size:12px;margin-top:6px">${esc(r.reason)}</div>` : ''}
|
||||
<div style="font-size:12px;color:var(--warning,#b45309);margin-top:8px">${esc(t('admin.sso_only.effect'))}</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
|
||||
<button class="btn btn-danger btn-sm" data-sso-approve="${esc(r.id)}">${esc(t('admin.sso_only.approve'))}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-sso-reject="${esc(r.id)}">${esc(t('admin.sso_only.reject'))}</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
const decide = async (id, decision) => {
|
||||
try {
|
||||
const res = await authed(`/organizations/sso-only/removal-requests/${id}/${decision}`, { method: 'POST', body: '{}' });
|
||||
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))).error) || String(res.status));
|
||||
showToast(t(decision === 'approve' ? 'admin.sso_only.approved' : 'admin.sso_only.rejected'), 'success');
|
||||
await loadSsoOnlyRequests();
|
||||
} catch (e) {
|
||||
showToast((e && e.message) || t('admin.sso_only.failed'), 'error');
|
||||
}
|
||||
};
|
||||
// Approving RE-OPENS password sign-in for a whole organization, so it is confirmed; rejecting
|
||||
// only leaves the safe state in place and is not.
|
||||
host.querySelectorAll('[data-sso-approve]').forEach((b) => b.addEventListener('click', () => {
|
||||
if (window.confirm(t('admin.sso_only.confirm'))) decide(b.dataset.ssoApprove, 'approve');
|
||||
}));
|
||||
host.querySelectorAll('[data-sso-reject]').forEach((b) => b.addEventListener('click', () => decide(b.dataset.ssoReject, 'reject')));
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
const el = document.getElementById('orgsTable');
|
||||
if (!el) return;
|
||||
|
|
@ -276,22 +350,28 @@ async function loadUsers() {
|
|||
<tbody>
|
||||
${users.map(u => `
|
||||
<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:8px"><div style="font-weight:500">${u.name || u.email}</div><div style="font-size:11px;color:var(--text-muted)">${u.email}</div></td>
|
||||
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
|
||||
<!-- ESCAPED: these come from self-registration and from an identity provider's
|
||||
email claim, so they are attacker-chosen. A reviewer registered an address whose
|
||||
local part was an img tag with an onerror handler, anonymously, and got script
|
||||
execution in the PLATFORM ADMIN's session on this page - the very page operators
|
||||
are now emailed to. Note backticks are illegal here: this sits inside a template
|
||||
literal. -->
|
||||
<td style="padding:8px"><div style="font-weight:500">${esc(u.name || u.email)}</div><div style="font-size:11px;color:var(--text-muted)">${esc(u.email)}</div></td>
|
||||
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${esc(u.auth_provider)}</span></td>
|
||||
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}</td>
|
||||
<td style="padding:8px">
|
||||
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
|
||||
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${esc(u.id)}">
|
||||
${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td style="padding:8px">
|
||||
<select class="input" style="max-width:130px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}">
|
||||
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
|
||||
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${esc(p.display_name)}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
${workspaceCell(u)}
|
||||
<td style="padding:8px;white-space:nowrap">
|
||||
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
|
||||
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${esc(u.id)}" data-user-email="${esc(u.email)}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
|
||||
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export async function render(container) {
|
|||
<div class="settings-section">
|
||||
<h3>${t('billing.current_plan')}</h3>
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
|
||||
<div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div>
|
||||
<div style="font-size:28px;font-weight:700;color:var(--accent)">${esc(subData.plan.display_name)}</div>
|
||||
${subData.self_hosted ? `<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.self_hosted')}</span>` : ''}
|
||||
${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.trial_days_left', { n: subData.trial.days_left })}</span>` : ''}
|
||||
</div>
|
||||
|
|
@ -75,7 +75,7 @@ export async function render(container) {
|
|||
${plans.map(p => `
|
||||
<div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative">
|
||||
${p.id === subData.plan.id ? `<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">${t('billing.current')}</div>` : ''}
|
||||
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div>
|
||||
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${esc(p.display_name)}</div>
|
||||
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
|
||||
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">${t('billing.per_month')}</span>` : t('billing.free')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ async function loadContent() {
|
|||
grid.innerHTML = content.map(c => {
|
||||
const exp = expiryInfo(c);
|
||||
return `
|
||||
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}" style="position:relative;${state.selected.has(c.id) ? 'outline:2px solid var(--primary,#3B82F6);outline-offset:-2px;' : ''}${exp.expired ? 'opacity:.55' : ''}">
|
||||
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${esc(c.folder || '')}" style="position:relative;${state.selected.has(c.id) ? 'outline:2px solid var(--primary,#3B82F6);outline-offset:-2px;' : ''}${exp.expired ? 'opacity:.55' : ''}">
|
||||
<label class="content-select-wrap" style="position:absolute;top:6px;left:6px;z-index:2;background:rgba(0,0,0,.55);border-radius:4px;padding:3px;display:flex;cursor:pointer">
|
||||
<input type="checkbox" class="content-select" data-content-id="${c.id}" ${state.selected.has(c.id) ? 'checked' : ''} style="width:16px;height:16px;margin:0;cursor:pointer">
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { api } from '../api.js';
|
||||
import { on, off, requestScreenshot } from '../socket.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc, livenessBadge } from '../utils.js';
|
||||
import { esc, livenessBadge, isPlatformAdmin } from '../utils.js';
|
||||
import { t, tn } from '../i18n.js';
|
||||
import * as gettingStarted from '../components/getting-started.js';
|
||||
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
|
||||
import { frameDeviceOutput } from '../lib/device-frame.js';
|
||||
|
||||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||||
// Command types only — labels resolved through t('dashboard.cmd.<type>')
|
||||
|
|
@ -78,6 +79,22 @@ function renderProgressFor(deviceId) {
|
|||
});
|
||||
}
|
||||
|
||||
// #238: a screenshot is the panel's raw framebuffer, so a device set to 90/270 sends a landscape
|
||||
// image with the content lying on its side — the wall mount is what turns it upright, and the card
|
||||
// had no stand-in for the mount. Every portrait screen in the fleet therefore looked wrong at a
|
||||
// glance on the one screen people scan to check the fleet is fine.
|
||||
//
|
||||
// Re-run after any render that replaces card markup; the orientation rides on the card so the
|
||||
// socket handler can re-frame a single card without re-reading the device list.
|
||||
function frameCard(stage) {
|
||||
const img = stage && stage.querySelector('img');
|
||||
if (img) frameDeviceOutput(stage, img, stage.dataset.orientation);
|
||||
}
|
||||
|
||||
function frameCardScreenshots(root) {
|
||||
(root || document).querySelectorAll('.device-card-preview[data-orientation]').forEach(frameCard);
|
||||
}
|
||||
|
||||
function renderDeviceCard(device) {
|
||||
const token = localStorage.getItem('token');
|
||||
const screenshotUrl = device.screenshot_path
|
||||
|
|
@ -85,12 +102,16 @@ function renderDeviceCard(device) {
|
|||
: null;
|
||||
|
||||
const checked = selectedDeviceIds.has(device.id);
|
||||
// A panel that cannot capture its own screen is not asked to, every 30 seconds, forever. The
|
||||
// list now carries the RESOLVED capability set (routes/devices.js), so a device that declares
|
||||
// nothing still reads as its platform baseline and keeps being polled exactly as today.
|
||||
const canShot = !Array.isArray(device.capabilities) || device.capabilities.includes('remote.screenshot');
|
||||
return `
|
||||
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
|
||||
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" data-can-screenshot="${canShot ? '1' : '0'}" onclick="window.location.hash='/device/${device.id}'">
|
||||
<label class="device-card-select" title="${t('dashboard.select_for_wall')}" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
|
||||
</label>
|
||||
<div class="device-card-preview" id="preview-${device.id}">
|
||||
<div class="device-card-preview" id="preview-${device.id}" data-orientation="${esc(device.orientation || 'landscape')}">
|
||||
${screenshotUrl
|
||||
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
|
||||
: `<div class="no-preview">
|
||||
|
|
@ -174,7 +195,9 @@ function renderWallCard(wall) {
|
|||
cells.push(`<div class="wall-card-cell${dev ? ' filled' : ''}" title="${dev ? esc(dev.device_name) : '[' + c + ',' + r + ']'}"></div>`);
|
||||
}
|
||||
}
|
||||
const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length;
|
||||
const members = wall.devices || [];
|
||||
const onlineCount = members.filter(d => d.device_status === 'online').length;
|
||||
const allUp = onlineCount === members.length && members.length > 0;
|
||||
return `
|
||||
<div class="device-card wall-card" data-wall-id="${wall.id}" onclick="window.location.hash='#/wall/${wall.id}'">
|
||||
<div class="device-card-preview wall-card-preview">
|
||||
|
|
@ -187,8 +210,20 @@ function renderWallCard(wall) {
|
|||
<div class="device-card-body">
|
||||
<div class="device-card-name">${esc(wall.name)}</div>
|
||||
<div class="device-card-meta">
|
||||
<div class="meta-item">${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}</div>
|
||||
<div class="meta-item" style="color:${onlineCount === (wall.devices || []).length ? 'var(--success)' : 'var(--text-muted)'}">${onlineCount} online</div>
|
||||
<div class="meta-item">${members.length} ${members.length === 1 ? 'tile' : 'tiles'}</div>
|
||||
<div class="meta-item" style="color:${allUp ? 'var(--success)' : 'var(--danger, #e5484d)'}">${allUp ? 'all online' : `${onlineCount}/${members.length} online`}</div>
|
||||
</div>
|
||||
<!-- #235: a wall replaces its members' cards, so without this strip one dead panel of a
|
||||
four-panel wall is invisible from the dashboard. Each chip links straight to the
|
||||
device page — being in a wall must not cost device-level visibility. -->
|
||||
<div class="wall-card-members" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px">
|
||||
${members.map(d => `
|
||||
<a class="wall-card-member" href="#/device/${esc(d.device_id)}" data-member-device-id="${esc(d.device_id)}" onclick="event.stopPropagation()"
|
||||
title="${esc(d.device_name)} — ${esc(d.device_status || 'unknown')}. Open device info & controls"
|
||||
style="display:inline-flex;align-items:center;gap:4px;max-width:120px;padding:1px 6px;border:1px solid var(--border);border-radius:10px;font-size:10px;color:var(--text-secondary);text-decoration:none">
|
||||
<span class="status-dot ${esc(d.device_status || 'offline')}" style="display:inline-block;flex-shrink:0"></span>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.device_name)}</span>
|
||||
</a>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -255,6 +290,47 @@ function renderGroupSection(group, devices, playlists) {
|
|||
`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Asks, once, whether this install will share its screen count. Only a platform admin sees it,
|
||||
* and only while the decision is genuinely unmade — BOTH answers persist, so it never returns
|
||||
* after an update. Re-prompting is how telemetry earns its reputation and gets patched out.
|
||||
*/
|
||||
async function renderStatsPrompt(container) {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
if (!isPlatformAdmin(user)) return;
|
||||
|
||||
let info;
|
||||
try { info = await api.adminGetTelemetry(); } catch { return; }
|
||||
if (info.state !== 'unasked') return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'settings-section';
|
||||
el.style.cssText = 'margin-bottom:16px;display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap';
|
||||
el.innerHTML = `
|
||||
<div style="flex:1;min-width:260px">
|
||||
<strong>Help show how widely ScreenTinker is deployed?</strong>
|
||||
<p style="color:var(--text-muted);font-size:13px;margin:6px 0 0">
|
||||
Because most installs are private, we can't tell how many screens are out there. Sharing
|
||||
sends a random ID, the version, and how many screens you run — nothing else, ever.
|
||||
You can change this any time in Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm" id="statsYes">Share</button>
|
||||
<button class="btn btn-secondary btn-sm" id="statsNo">No thanks</button>
|
||||
</div>
|
||||
`;
|
||||
container.prepend(el);
|
||||
|
||||
const answer = async (enabled) => {
|
||||
try { await api.adminSetTelemetry(enabled); } catch { /* leave it unasked; it can ask again later */ return; }
|
||||
el.remove();
|
||||
if (enabled) showToast('Thank you — sharing install statistics', 'success');
|
||||
};
|
||||
el.querySelector('#statsYes').addEventListener('click', () => answer(true));
|
||||
el.querySelector('#statsNo').addEventListener('click', () => answer(false));
|
||||
}
|
||||
|
||||
export function render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
|
|
@ -397,6 +473,10 @@ export function render(container) {
|
|||
// Load everything
|
||||
loadDashboard();
|
||||
|
||||
// Ask once about sharing install statistics. Fire-and-forget: it prepends itself if and only
|
||||
// if the decision is still unmade, and a failure here must never affect the dashboard.
|
||||
renderStatsPrompt(container).catch(() => {});
|
||||
|
||||
// Real-time updates
|
||||
statusHandler = (data) => {
|
||||
const b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text
|
||||
|
|
@ -405,6 +485,14 @@ export function render(container) {
|
|||
const statusEl = card.querySelector('.device-card-status');
|
||||
if (statusEl) statusEl.innerHTML = `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`;
|
||||
});
|
||||
// #235: a wall member has no card of its own, only a chip on the wall card. Without this a
|
||||
// panel could go offline and the dashboard would keep showing it green until a full reload —
|
||||
// exactly the blind spot the issue is about.
|
||||
document.querySelectorAll(`.wall-card-member[data-member-device-id="${CSS.escape(data.device_id)}"]`).forEach(chip => {
|
||||
const dot = chip.querySelector('.status-dot');
|
||||
if (dot) dot.className = `status-dot ${b.state}`;
|
||||
chip.title = `${chip.title.split(' — ')[0]} — ${b.label}. Open device info & controls`;
|
||||
});
|
||||
};
|
||||
|
||||
screenshotHandler = (data) => {
|
||||
|
|
@ -417,6 +505,7 @@ export function render(container) {
|
|||
const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || '';
|
||||
preview.innerHTML = `<img src="${imgSrc}" alt="Screenshot" loading="lazy">${statusHtml}`;
|
||||
}
|
||||
frameCard(preview); // the branch above can swap the img element out from under us
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -446,18 +535,14 @@ export function render(container) {
|
|||
for (const id of playbackByDevice.keys()) renderProgressFor(id);
|
||||
}, 1000);
|
||||
|
||||
// Request fresh screenshots on load
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.device-card').forEach(card => {
|
||||
// Request fresh screenshots on load — from the panels that can actually take one.
|
||||
const pollScreenshots = () => {
|
||||
document.querySelectorAll('.device-card[data-can-screenshot="1"]').forEach(card => {
|
||||
requestScreenshot(card.dataset.deviceId);
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
refreshInterval = setInterval(() => {
|
||||
document.querySelectorAll('.device-card').forEach(card => {
|
||||
requestScreenshot(card.dataset.deviceId);
|
||||
});
|
||||
}, 30000);
|
||||
};
|
||||
setTimeout(pollScreenshots, 2000);
|
||||
refreshInterval = setInterval(pollScreenshots, 30000);
|
||||
}
|
||||
|
||||
function refreshSelectionBar() {
|
||||
|
|
@ -659,6 +744,7 @@ async function loadDashboard() {
|
|||
}
|
||||
|
||||
main.innerHTML = html;
|
||||
frameCardScreenshots();
|
||||
attachGroupHandlers(groupsWithDevices, dashboardDevices);
|
||||
|
||||
// Drop any selections for devices that have since been absorbed into a
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@ function redraw() {
|
|||
break;
|
||||
case 'countdown':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
|
||||
<div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${el.label || ''}</div>
|
||||
<div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${esc(el.label || '')}</div>
|
||||
<div style="font-size:${el.fontSize / 10}cqw;font-weight:bold" id="countdown_${i}"></div>
|
||||
</div>`;
|
||||
break;
|
||||
|
|
@ -695,7 +695,7 @@ function updateProps() {
|
|||
<div class="form-group"><label>${t('designer.prop.opacity')}</label><input type="range" min="0" max="1" step="0.1" value="${el.opacity}" data-prop="opacity" style="width:100%"></div>
|
||||
<div class="form-group"><label>${t('designer.prop.shape')}</label><select class="input" style="background:var(--bg-input)" data-prop="shape"><option ${el.shape === 'rect' ? 'selected' : ''}>rect</option><option ${el.shape === 'circle' ? 'selected' : ''}>circle</option></select></div>`;
|
||||
} else if (el.type === 'weather') {
|
||||
html += `<div class="form-group"><label>${t('designer.prop.location')}</label><input type="text" class="input" value="${el.location}" data-prop="location"></div>
|
||||
html += `<div class="form-group"><label>${t('designer.prop.location')}</label><input type="text" class="input" value="${esc(el.location)}" data-prop="location"></div>
|
||||
<div class="form-group"><label>${t('widget.field.units')}</label><select class="input" data-prop="units">
|
||||
<option value="imperial" ${el.units !== 'metric' ? 'selected' : ''}>${t('widget.field.units_imperial')}</option>
|
||||
<option value="metric" ${el.units === 'metric' ? 'selected' : ''}>${t('widget.field.units_metric')}</option>
|
||||
|
|
@ -709,7 +709,7 @@ function updateProps() {
|
|||
<div class="form-group"><label>${t('designer.prop.bg_color')}</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`;
|
||||
} else if (el.type === 'countdown') {
|
||||
html += `<div class="form-group"><label>${t('designer.prop.target_date')}</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div>
|
||||
<div class="form-group"><label>${t('designer.prop.label')}</label><input type="text" class="input" value="${el.label}" data-prop="label"></div>
|
||||
<div class="form-group"><label>${t('designer.prop.label')}</label><input type="text" class="input" value="${esc(el.label)}" data-prop="label"></div>
|
||||
<div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="100" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
|
||||
<div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
|
||||
}
|
||||
|
|
@ -797,7 +797,7 @@ function generateInnerHTML() {
|
|||
break;
|
||||
case 'countdown':
|
||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color}">
|
||||
<div style="font-size:${fsLabel}vw;opacity:0.8">${el.label}</div>
|
||||
<div style="font-size:${fsLabel}vw;opacity:0.8">${esc(el.label)}</div>
|
||||
<div style="font-size:${fs}vw;font-weight:bold" id="cd${i}"></div></div>
|
||||
<script>setInterval(()=>{const d=new Date('${el.targetDate}')-new Date();if(d<=0){document.getElementById('cd${i}').textContent='NOW!';return}document.getElementById('cd${i}').textContent=Math.floor(d/864e5)+'d '+Math.floor(d%864e5/36e5)+'h '+Math.floor(d%36e5/6e4)+'m'},6e4)</script>`;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { showToast } from '../components/toast.js';
|
|||
import { esc, livenessBadge, hydrateAuthImages } from '../utils.js';
|
||||
import { t, tn } from '../i18n.js';
|
||||
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
|
||||
import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js';
|
||||
|
||||
// The player distinguishes three cases for the Wi-Fi name, because "--" was hiding a real
|
||||
// answer: Android 8.1+ refuses to reveal the SSID to an app without location permission, and a
|
||||
|
|
@ -15,6 +16,14 @@ function ssidLabel(ssid) {
|
|||
return esc(ssid);
|
||||
}
|
||||
|
||||
// #238: turn the Now Playing screenshot the way the wall mount turns the panel. The placeholder
|
||||
// ("no screenshot yet") is deliberately left alone — it is dashboard chrome, not device output.
|
||||
function frameNowPlaying() {
|
||||
const stage = document.getElementById('screenshotStage');
|
||||
const img = document.getElementById('currentScreenshot');
|
||||
if (stage && img && img.tagName === 'IMG') frameDeviceOutput(stage, img, currentDevice?.orientation);
|
||||
}
|
||||
|
||||
let currentDevice = null;
|
||||
let statusHandler = null;
|
||||
let screenshotHandler = null;
|
||||
|
|
@ -24,6 +33,81 @@ let shellHandler = null;
|
|||
let diagPollTimer = null; // polls a diag-smoothness widget's reported frame stats while the page is open
|
||||
let screenshotInterval = null;
|
||||
let remoteActive = false;
|
||||
// Mirrors the Debug-logging checkbox so cleanup() can switch the device's stream back off.
|
||||
// Without this, leaving the screen left the panel streaming into nothing: the device kept
|
||||
// emitting, the dashboard kept relaying, and nobody was listening. The player carries its own
|
||||
// auto-off as the backstop for the case this can't cover -- a tab that is killed, not closed.
|
||||
let debugStreamOn = false;
|
||||
let debugFrozen = false;
|
||||
let debugHeld = []; // lines that arrived while frozen, replayed on resume
|
||||
const DEBUG_PANEL_MAX = 500; // panel rows AND the held-while-frozen cap
|
||||
|
||||
// Every player sends a level and the panel used to render all four identically, so the one line
|
||||
// that explains the fault sat in a wall of grey. Errors and warnings are why the operator opened it.
|
||||
const DEBUG_LEVEL_COLOR = { e: '#f87171', w: '#fbbf24', d: '#64748b' };
|
||||
|
||||
function debugLineText(d) {
|
||||
return `${new Date(d.ts || Date.now()).toLocaleTimeString()} [${d.tag || ''}] ${d.message || ''}`;
|
||||
}
|
||||
|
||||
function appendDebugLine(d) {
|
||||
const panel = document.getElementById('debugLogPanel');
|
||||
if (!panel) return;
|
||||
const line = document.createElement('div');
|
||||
line.textContent = debugLineText(d); // textContent — no HTML injection
|
||||
const tone = DEBUG_LEVEL_COLOR[(d.level || '').toLowerCase()];
|
||||
if (tone) line.style.color = tone;
|
||||
panel.appendChild(line);
|
||||
while (panel.childElementCount > DEBUG_PANEL_MAX) panel.removeChild(panel.firstChild);
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
|
||||
function updateDebugTools() {
|
||||
const btn = document.getElementById('debugFreezeBtn');
|
||||
const status = document.getElementById('debugLogStatus');
|
||||
if (btn) btn.textContent = debugFrozen ? t('device.debug.resume') : t('device.debug.freeze');
|
||||
if (status) {
|
||||
// Say how many are waiting, so freezing never feels like the device went quiet.
|
||||
status.textContent = debugFrozen
|
||||
? (debugHeld.length >= DEBUG_PANEL_MAX
|
||||
? t('device.debug.held_max', { n: debugHeld.length })
|
||||
: t('device.debug.held', { n: debugHeld.length }))
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function setDebugFrozen(frozen) {
|
||||
debugFrozen = frozen;
|
||||
if (!frozen) {
|
||||
const held = debugHeld;
|
||||
debugHeld = [];
|
||||
for (const d of held) appendDebugLine(d); // resume shows what you missed, in order
|
||||
}
|
||||
updateDebugTools();
|
||||
}
|
||||
|
||||
/*
|
||||
* Clipboard with a fallback, because a self-hosted dashboard on plain http is NOT a secure context
|
||||
* and `navigator.clipboard` is simply absent there — the copy buttons elsewhere in this app quietly
|
||||
* do nothing in that case. A debug log is precisely what a self-hoster wants to paste into an issue.
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
|
||||
} catch (e) { /* fall through to the legacy path */ }
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, ta.value.length);
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// Belt for the orphaned-stream fix: if the tab is hidden/closed/backgrounded while a Remote session
|
||||
// is live, stop it (the server also auto-stops on socket drop, but bfcache keeps the socket alive).
|
||||
|
|
@ -93,6 +177,39 @@ function isBrightSignDevice(device) {
|
|||
return String(device.platform || '').toLowerCase().includes('brightsign');
|
||||
}
|
||||
|
||||
// Mirrors platformFamily() in server/lib/player-capabilities.js — SAME FOUR SIGNALS, SAME ORDER,
|
||||
// so the UI and the server never disagree about what a device is.
|
||||
//
|
||||
// The precedence is the whole point and is easy to get wrong. An earlier version of this helper
|
||||
// kept only the last test, and a Tizen TV registers `android_version: 'Tizen 6.5'` (see
|
||||
// tizen/js/app.js) — non-empty, not "Web/..." — so every Samsung panel in the fleet classified as
|
||||
// Android. It was invisible only because Tizen happens to declare remote.screenshot today; the
|
||||
// moment that changes, a MediaProjection button appears on a TV that has no such API.
|
||||
//
|
||||
// Gates the MediaProjection capture bootstrap below, and that gate is deliberately Android-and-
|
||||
// nothing-else — NOT "Android that cannot already capture".
|
||||
//
|
||||
// The tempting extra condition is to hide it once a panel declares remote.screenshot. Two reasons
|
||||
// not to. First, the dashboard cannot tell "this device declared it" from "the server filled in a
|
||||
// baseline": /api/devices/:id ships capabilitiesFor(), which resolves both into one array (see
|
||||
// server/routes/devices.js), and the android baseline CONTAINS remote.screenshot — so that
|
||||
// condition hides the button from every one of the ~440 undeclared panels in the field, which is
|
||||
// exactly backwards. Second, even where capture already works it is the accessibility path;
|
||||
// MediaProjection is the better one (WebSocketService tries it FIRST), so offering the upgrade to
|
||||
// a panel that has the weaker path is a feature, not redundancy.
|
||||
function isAndroidDevice(device) {
|
||||
if (!device) return false;
|
||||
const platform = String(device.platform || '').toLowerCase();
|
||||
if (platform.includes('brightsign')) return false;
|
||||
if (platform.includes('tizen')) return false;
|
||||
// Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt'. `platform`
|
||||
// is the primary key, but it lives in a column an older client's register could overwrite.
|
||||
if (device.client_type === 'wgt') return false;
|
||||
if (device.client_type === 'apk') return true;
|
||||
const av = String(device.android_version || '');
|
||||
return av !== '' && !av.startsWith('Web/');
|
||||
}
|
||||
|
||||
export function render(container, deviceId) {
|
||||
container.innerHTML = `
|
||||
<div class="device-detail">
|
||||
|
|
@ -144,6 +261,10 @@ export function render(container, deviceId) {
|
|||
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
|
||||
screenshotEl.replaceWith(img);
|
||||
}
|
||||
// #238: a screenshot is the RAW framebuffer, so a portrait panel's arrives sideways — the
|
||||
// player rotated the content into it and only the wall mount turns it back. Re-frame on every
|
||||
// arrival, not just at render: the branch above swaps the element out from under us.
|
||||
frameNowPlaying();
|
||||
}
|
||||
// Update remote canvas
|
||||
const canvas = document.getElementById('remoteCanvas');
|
||||
|
|
@ -171,14 +292,16 @@ export function render(container, deviceId) {
|
|||
// checkbox is on). Appended via textContent — no HTML injection.
|
||||
logHandler = (data) => {
|
||||
if (data.device_id !== deviceId) return;
|
||||
const panel = document.getElementById('debugLogPanel');
|
||||
if (!panel) return;
|
||||
const line = document.createElement('div');
|
||||
const time = new Date(data.ts || Date.now()).toLocaleTimeString();
|
||||
line.textContent = `${time} [${data.tag || ''}] ${data.message || ''}`;
|
||||
panel.appendChild(line);
|
||||
while (panel.childElementCount > 500) panel.removeChild(panel.firstChild);
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
// Frozen: HOLD the line rather than drop it. A log you froze to read something is the exact
|
||||
// moment the lines that explain it are still arriving — pausing the stream would throw away
|
||||
// the part you were about to want.
|
||||
if (debugFrozen) {
|
||||
debugHeld.push(data);
|
||||
if (debugHeld.length > DEBUG_PANEL_MAX) debugHeld.shift();
|
||||
updateDebugTools();
|
||||
return;
|
||||
}
|
||||
appendDebugLine(data);
|
||||
};
|
||||
|
||||
on('device-status', statusHandler);
|
||||
|
|
@ -214,7 +337,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
contentEl.innerHTML = `
|
||||
<div class="device-header">
|
||||
<div class="device-header-left">
|
||||
<h1 id="deviceName">${device.name}</h1>
|
||||
<h1 id="deviceName">${esc(device.name)}</h1>
|
||||
${(() => { const b = livenessBadge(device); return `<span class="device-status-badge ${b.state}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`; })()}
|
||||
${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''}
|
||||
</div>
|
||||
|
|
@ -260,7 +383,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
|
||||
<!-- Now Playing Tab -->
|
||||
<div class="tab-content active" id="tab-nowplaying">
|
||||
<div class="screenshot-container">
|
||||
<div class="screenshot-container" id="screenshotStage">
|
||||
${device.screenshot
|
||||
? `<img id="currentScreenshot" src="/api/devices/${device.id}/screenshot?t=${Date.now()}&token=${localStorage.getItem('token')}" alt="Current screen">`
|
||||
: `<div class="no-screenshot" id="currentScreenshot">
|
||||
|
|
@ -269,7 +392,10 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span>${t('device.no_screenshot')}</span>
|
||||
<!-- The default copy tells the operator to click a button that is only rendered
|
||||
for a panel that can capture. On one that cannot, pointing at a control that
|
||||
is not on the page reads as a broken dashboard. -->
|
||||
<span>${can('remote.screenshot') ? t('device.no_screenshot') : t('device.no_screenshot_unsupported')}</span>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
|
|
@ -334,6 +460,63 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<!-- Info Tab -->
|
||||
<div class="tab-content" id="tab-info">
|
||||
${diagWidget ? renderDiagPanel(diagWidget) : ''}
|
||||
|
||||
<!-- The actions an operator opens this page to take. They used to sit below the info
|
||||
grid, the reboot schedule and the debug log panel, which on a phone meant scrolling
|
||||
past everything to reach the one button you came for. Kept as a single wrapping row
|
||||
so a narrow screen reflows rather than clipping, and each button still renders only
|
||||
where the display can honour it. -->
|
||||
<div style="margin:20px 0;display:flex;gap:8px;flex-wrap:wrap">
|
||||
${can('system.reboot') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="rebootBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
${t('device.ctl.reboot_device')}
|
||||
</button>` : ''}
|
||||
${can('display.power') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="screenOffBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
${t('device.ctl.screen_off')}
|
||||
</button>` : ''}
|
||||
${can('display.power') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="screenOnBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
${t('device.ctl.screen_on')}
|
||||
</button>` : ''}
|
||||
${can('system.restart_player') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="launchAppBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
${t('device.ctl.launch_player')}
|
||||
</button>` : ''}
|
||||
${can('system.self_update') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="forceUpdateBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
${t('device.ctl.force_update')}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="clearUpdateCacheBtn" title="${t('device.ctl.clear_update_cache_tip')}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
${t('device.ctl.clear_update_cache')}
|
||||
</button>` : ''}
|
||||
${can('system.reboot') ? `
|
||||
<button class="btn btn-danger btn-sm" id="shutdownBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
|
||||
</svg>
|
||||
${t('device.ctl.shutdown')}
|
||||
</button>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.status')}</div>
|
||||
|
|
@ -350,6 +533,15 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.info.local_ip')}</div>
|
||||
<div class="info-card-value small" id="telLocalIp">${device.local_ip || '--'}</div>
|
||||
</div>
|
||||
${device.local_ip6 ? `
|
||||
<div class="info-card">
|
||||
<!-- Rendered only when the panel actually has one. A v6 address is long, and showing an
|
||||
empty row for the overwhelmingly v4 fleet would cost every operator screen space to
|
||||
tell them nothing. A dual-stack panel shows both cards; a v6-only panel used to
|
||||
show a dash here and nothing else, because the player only ever collected v4. -->
|
||||
<div class="info-card-label">${t('device.info.local_ip6')}</div>
|
||||
<div class="info-card-value small" id="telLocalIp6">${device.local_ip6}</div>
|
||||
</div>` : ''}
|
||||
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.battery')}</div>
|
||||
|
|
@ -391,10 +583,15 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
</div>` : ''}
|
||||
${latestTelemetry.storage_total_mb ? `
|
||||
<div class="info-card">
|
||||
<!-- Labelled "player storage", not "storage": on this family the number is the
|
||||
widget's cache quota, not the device filesystem. Same column as Android's real
|
||||
disk figures, so the label is what stops it being read as "the disk is 1 GB". -->
|
||||
<div class="info-card-label">${t('device.info.player_storage')}</div>
|
||||
<!-- This used to be labelled "player storage" because the number WAS the widget's
|
||||
cache quota rather than the disk — a real XT245 with a 119 GB NVMe reported
|
||||
"1026 MB", and the label was the only thing stopping that being read as the disk
|
||||
size. The bridge now reads the actual filesystem (statfs over the mounts under
|
||||
/storage, largest wins), so it means the same thing as Android's figure and is
|
||||
labelled the same. ⚠️ The bridge is served per page load, so a player that has not
|
||||
re-fetched it yet still reports the quota — see the CDN caching note in
|
||||
docs/player-parity.md before trusting a suspiciously round ~1 GB here. -->
|
||||
<div class="info-card-label">${t('device.info.storage')}</div>
|
||||
<div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb != null ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}"
|
||||
|
|
@ -407,6 +604,20 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.info.temperature')}</div>
|
||||
<div class="info-card-value small" id="telTemp">${latestTelemetry.temperature_c}°C</div>
|
||||
</div>` : ''}
|
||||
<!-- The physical panel, from its EDID, and the mode the output is negotiated to. Shown
|
||||
only when the player reports them, like every other card here: a family that cannot
|
||||
read its own output must not grow an empty row. On a dual-output player each device
|
||||
row is one output, so this is THAT output's screen — not the box's first. -->
|
||||
${latestTelemetry.attached_display ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.attached_display')}</div>
|
||||
<div class="info-card-value small" id="telDisplay">${esc(latestTelemetry.attached_display)}</div>
|
||||
</div>` : ''}
|
||||
${latestTelemetry.video_mode ? `
|
||||
<div class="info-card">
|
||||
<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>` : ''}
|
||||
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.wifi')}</div>
|
||||
|
|
@ -452,11 +663,18 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.clock.label')}</div>
|
||||
<div class="info-card-value small">${renderDeviceClock(device)}</div>
|
||||
</div>
|
||||
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||
<!-- Shown for Android as before, and now for ANY player that actually reports the value.
|
||||
These were platform-gated when Android was the only family that could measure them;
|
||||
a BrightSign widget runs with nodejs_enabled and the bridge reads os.totalmem/freemem
|
||||
and the load average, so the numbers exist and were being thrown away by a gate that
|
||||
asked what the device IS instead of what it SENT. Keeping the Android arm means a
|
||||
panel that reports nothing still shows "--" there rather than losing its cards. -->
|
||||
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.ram_free_mb != null ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.ram')}</div>
|
||||
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.cpu_usage != null ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.cpu_usage')}</div>
|
||||
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>
|
||||
|
|
@ -548,53 +766,19 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<input type="checkbox" id="debugLogToggle"> ${t('device.debug.toggle')}
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.debug.hint')}</div>
|
||||
<!-- Freeze holds the view still WITHOUT dropping what arrives: a log you are reading
|
||||
scrolls the interesting line off the top, and pausing the stream instead would lose
|
||||
exactly the lines that follow the fault. Copy exists because the useful next step is
|
||||
pasting this into an issue. -->
|
||||
<div id="debugLogTools" style="display:none;margin-top:8px;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="debugFreezeBtn">${t('device.debug.freeze')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="debugCopyBtn">${t('device.debug.copy')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="debugClearBtn">${t('device.debug.clear')}</button>
|
||||
<span id="debugLogStatus" style="font-size:11px;color:var(--text-muted)"></span>
|
||||
</div>
|
||||
<div id="debugLogPanel" style="display:none;margin-top:8px;background:#0b0f1a;border:1px solid var(--border);border-radius:6px;padding:8px;height:220px;overflow-y:auto;font-family:monospace;font-size:11px;line-height:1.45;color:#cbd5e1"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap">
|
||||
${can('system.reboot') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="rebootBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
${t('device.ctl.reboot_device')}
|
||||
</button>` : ''}
|
||||
${can('display.power') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="screenOffBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
${t('device.ctl.screen_off')}
|
||||
</button>` : ''}
|
||||
${can('display.power') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="screenOnBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
${t('device.ctl.screen_on')}
|
||||
</button>` : ''}
|
||||
${can('system.restart_player') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="launchAppBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
${t('device.ctl.launch_player')}
|
||||
</button>` : ''}
|
||||
${can('system.self_update') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="forceUpdateBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
${t('device.ctl.force_update')}
|
||||
</button>` : ''}
|
||||
${can('system.reboot') ? `
|
||||
<button class="btn btn-danger btn-sm" id="shutdownBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
|
||||
</svg>
|
||||
${t('device.ctl.shutdown')}
|
||||
</button>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- #109: PiP overlay tester. Pushes device:pip-show/clear via POST /api/pip
|
||||
(real triggers are external via the API token; this is for testing). -->
|
||||
|
|
@ -626,6 +810,11 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="remote-container">
|
||||
${can('remote.stream') ? `
|
||||
<div class="remote-screen" id="remoteScreen">
|
||||
<!-- Deliberately NOT rotated with the rest of the previews (#238). This is a control
|
||||
surface: taps and swipes are sent as fractions of THIS canvas, which is the raw
|
||||
framebuffer the device replays them into, and it also shows the Android system UI —
|
||||
which really is landscape on a portrait-hung panel. Turning the picture without
|
||||
inverting the touch mapping would send every tap to the wrong place. -->
|
||||
<canvas id="remoteCanvas" width="960" height="540" style="background:#000;width:100%"></canvas>
|
||||
<div class="no-screenshot" id="remoteOverlay" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
||||
<div style="text-align:center">
|
||||
|
|
@ -665,16 +854,17 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">${t('device.remote.ok')}</button>
|
||||
<hr style="border-color:var(--border);margin:8px 0">
|
||||
<button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">${t('device.remote.settings')}</button>
|
||||
${can('display.power') ? `
|
||||
<hr style="border-color:var(--border);margin:8px 0">
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">${t('device.remote.scrn_off')}</button>
|
||||
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">${t('device.remote.scrn_on')}</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>` : ''}
|
||||
${device.tier === 2 ? `
|
||||
<span style="font-size:10px;color:var(--success);line-height:1.2;display:block;margin-top:8px">${t('device.remote.system_view_owner')}</span>
|
||||
` : `
|
||||
${can('remote.screenshot') ? `
|
||||
${isAndroidDevice(device) ? `
|
||||
<button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="${t('device.remote.system_view_tooltip')}" style="margin-top:8px">
|
||||
${t('device.remote.enable_system_view')}
|
||||
</button>
|
||||
|
|
@ -796,6 +986,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
// offline→online transitions derived from the status log).
|
||||
renderIncidents(device.deviceEvents || [], device.statusLog || []);
|
||||
|
||||
frameNowPlaying();
|
||||
setupTabs();
|
||||
setupActions(device);
|
||||
setupRemote(device);
|
||||
|
|
@ -905,8 +1096,13 @@ function setupTabs() {
|
|||
// same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT
|
||||
// playlist in the device's OWN layout/orientation (server payload). wall members
|
||||
// preview full-frame (server forces wall_config:null in v1).
|
||||
//
|
||||
// #238: the iframe is the panel's FRAMEBUFFER, not its face. It used to be given the as-displayed
|
||||
// 9/16 shape directly, so on a portrait device the player rotated content a second time inside a
|
||||
// box that was already the finished picture and the preview came out sideways — while the panel
|
||||
// itself was right, which is the worst possible split for someone trying to verify their work.
|
||||
// The stage is the face; the frame is landscape underneath it and the mount turns it back.
|
||||
function showDevicePreview(device) {
|
||||
const portrait = (device.orientation || '').includes('portrait');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
|
||||
overlay.innerHTML = `
|
||||
|
|
@ -916,10 +1112,13 @@ function showDevicePreview(device) {
|
|||
<button class="btn btn-secondary btn-sm" id="dpvClose">${t('widget.close')}</button>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
|
||||
<iframe style="height:78vh;max-width:92vw;aspect-ratio:${portrait ? '9 / 16' : '16 / 9'};border:0;background:#000" src="/player?preview=1&device=${encodeURIComponent(device.id)}&t=${Date.now()}"></iframe>
|
||||
<div id="dpvStage" style="height:78vh;max-width:92vw;aspect-ratio:${displayAspectRatio(device.orientation)};background:#000">
|
||||
<iframe style="border:0;background:#000" src="/player?preview=1&device=${encodeURIComponent(device.id)}&t=${Date.now()}"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
frameDeviceOutput(overlay.querySelector('#dpvStage'), overlay.querySelector('#dpvStage iframe'), device.orientation);
|
||||
const close = () => overlay.remove();
|
||||
overlay.querySelector('#dpvClose').onclick = close;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
||||
|
|
@ -1053,10 +1252,16 @@ function setupActions(device) {
|
|||
|
||||
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
|
||||
|
||||
// Screenshot button
|
||||
// Screenshot button — pass a callback so the server's verdict surfaces as a toast
|
||||
// instead of the request silently going nowhere (offline device, or a player type
|
||||
// that can't capture at all, e.g. BrightSign).
|
||||
document.getElementById('screenshotBtn')?.addEventListener('click', () => {
|
||||
requestScreenshot(device.id);
|
||||
showToast(t('device.toast.screenshot_requested'), 'info');
|
||||
requestScreenshot(device.id, (ack) => {
|
||||
if (ack?.delivered) showToast(t('device.toast.screenshot_requested'), 'info');
|
||||
else if (ack?.reason === 'unsupported') showToast(t('device.toast.screenshot_unsupported'), 'warning');
|
||||
else if (ack?.reason === 'offline') showToast(t('device.toast.screenshot_offline'), 'warning');
|
||||
else showToast(t('device.toast.screenshot_failed'), 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Rename
|
||||
|
|
@ -1097,9 +1302,36 @@ function setupActions(device) {
|
|||
const enabled = e.target.checked;
|
||||
const panel = document.getElementById('debugLogPanel');
|
||||
if (panel) panel.style.display = enabled ? 'block' : 'none';
|
||||
const tools = document.getElementById('debugLogTools');
|
||||
if (tools) tools.style.display = enabled ? 'flex' : 'none';
|
||||
debugStreamOn = enabled;
|
||||
// Unticking and reticking should not resume into a frozen panel the operator forgot about.
|
||||
if (!enabled) { debugFrozen = false; debugHeld = []; }
|
||||
updateDebugTools();
|
||||
sendCommand(device.id, 'set_debug', { enabled });
|
||||
});
|
||||
|
||||
document.getElementById('debugFreezeBtn')?.addEventListener('click', () => setDebugFrozen(!debugFrozen));
|
||||
|
||||
document.getElementById('debugClearBtn')?.addEventListener('click', () => {
|
||||
const panel = document.getElementById('debugLogPanel');
|
||||
if (panel) panel.textContent = '';
|
||||
debugHeld = [];
|
||||
updateDebugTools();
|
||||
});
|
||||
|
||||
document.getElementById('debugCopyBtn')?.addEventListener('click', async () => {
|
||||
const panel = document.getElementById('debugLogPanel');
|
||||
// Copy what is ON SCREEN. Anything held while frozen is deliberately excluded — the operator
|
||||
// is copying the capture they are looking at, and silently appending lines they have not seen
|
||||
// would make the paste disagree with the panel.
|
||||
const text = panel ? [...panel.children].map((el) => el.textContent).join('\n') : '';
|
||||
if (!text) { showToast(t('device.debug.copy_empty'), 'error'); return; }
|
||||
const header = `${device.name || device.id} — ${device.platform || ''} ${device.hardware_model || ''} — ${new Date().toISOString()}`.trim();
|
||||
const ok = await copyToClipboard(`${header}\n${'-'.repeat(header.length)}\n${text}\n`);
|
||||
showToast(ok ? t('device.debug.copied', { n: panel.childElementCount }) : t('device.debug.copy_failed'), ok ? 'success' : 'error');
|
||||
});
|
||||
|
||||
document.getElementById('saveNotesBtn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.updateDevice(device.id, {
|
||||
|
|
@ -1367,6 +1599,12 @@ function setupActions(device) {
|
|||
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
|
||||
});
|
||||
|
||||
// Drops every staged APK on the panel so the next check downloads afresh. The escape hatch for a
|
||||
// player holding a bad download — a cached file that cannot install but is reused every attempt.
|
||||
document.getElementById('clearUpdateCacheBtn')?.addEventListener('click', () => {
|
||||
sendWithFeedback('clear_update_cache', 'Clear update cache', 'device.toast.update_cache_cleared');
|
||||
});
|
||||
|
||||
// #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
|
||||
document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
|
||||
const uri = (document.getElementById('pipUri')?.value || '').trim();
|
||||
|
|
@ -1549,7 +1787,7 @@ async function setupPlaylistActions(device) {
|
|||
${zones.length > 0 ? `
|
||||
<select id="assignZone" class="input" style="background:var(--bg-input)">
|
||||
<option value="">${t('device.assign.zone_default')}</option>
|
||||
${zones.map(z => `<option value="${z.id}">${z.name} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')}
|
||||
${zones.map(z => `<option value="${z.id}">${esc(z.name)} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')}
|
||||
</select>
|
||||
` : !device.layout_id ? `
|
||||
<div style="font-size:12px;color:var(--text-muted);padding:6px 0;line-height:1.5">${t('device.assign.zone_no_layout')}</div>
|
||||
|
|
@ -1561,7 +1799,9 @@ async function setupPlaylistActions(device) {
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t('device.assign.duration_label')}</label>
|
||||
<input type="number" id="assignDuration" class="input" value="10" min="1" max="3600">
|
||||
<!-- max is the server's absurd-duration ceiling (12h): a feature-length clip
|
||||
pre-filled from its own length must not land in an out-of-range field. -->
|
||||
<input type="number" id="assignDuration" class="input" value="10" min="1" max="43200">
|
||||
</div>
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:12px">
|
||||
|
|
@ -1572,7 +1812,7 @@ async function setupPlaylistActions(device) {
|
|||
<!-- Media grid -->
|
||||
<div class="assign-content-grid" id="assignMedia">
|
||||
${content.map(c => `
|
||||
<div class="assign-content-item" data-content-id="${c.id}" data-type="content">
|
||||
<div class="assign-content-item" data-content-id="${c.id}" data-type="content" data-duration="${Number(c.duration_sec) > 0 ? Math.ceil(c.duration_sec) : ''}">
|
||||
${c.thumbnail_path
|
||||
? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="">`
|
||||
: c.remote_url
|
||||
|
|
@ -1596,7 +1836,7 @@ async function setupPlaylistActions(device) {
|
|||
<div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">
|
||||
${icons[w.widget_type] || '⚙'}
|
||||
</div>
|
||||
<div class="assign-content-item-name">${w.name}</div>
|
||||
<div class="assign-content-item-name">${esc(w.name)}</div>
|
||||
</div>`;
|
||||
}).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_widgets')} <a href="#/widgets" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
|
||||
</div>
|
||||
|
|
@ -1605,7 +1845,7 @@ async function setupPlaylistActions(device) {
|
|||
${kioskPages.map(k => `
|
||||
<div class="assign-content-item" data-content-id="${k.id}" data-type="kiosk">
|
||||
<div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">🖱</div>
|
||||
<div class="assign-content-item-name">${k.name}</div>
|
||||
<div class="assign-content-item-name">${esc(k.name)}</div>
|
||||
</div>
|
||||
`).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_kiosk')} <a href="#/kiosk" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
|
||||
</div>
|
||||
|
|
@ -1632,12 +1872,21 @@ async function setupPlaylistActions(device) {
|
|||
|
||||
let selectedId = null;
|
||||
let selectedType = null;
|
||||
// #237: this modal always SENDS a duration, so the server's "default a video to its own
|
||||
// length" rule can never fire here — the field has to carry the clip length itself, or
|
||||
// picking a 32s video silently assigns a 10s item that cuts off. Anything the operator
|
||||
// typed is theirs and is never overwritten.
|
||||
const durInput = modal.querySelector('#assignDuration');
|
||||
let durationTouched = false;
|
||||
durInput?.addEventListener('input', () => { durationTouched = true; });
|
||||
modal.querySelectorAll('.assign-content-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
modal.querySelectorAll('.assign-content-item').forEach(i => i.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
selectedId = item.dataset.contentId;
|
||||
selectedType = item.dataset.type;
|
||||
const clip = parseInt(item.dataset.duration || '', 10);
|
||||
if (durInput && !durationTouched) durInput.value = clip > 0 ? clip : 10;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2028,6 +2277,9 @@ function updateTelemetryDisplay(telemetry) {
|
|||
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
|
||||
if (telemetry.wifi_ssid !== undefined) update('telWifi', ssidLabel(telemetry.wifi_ssid));
|
||||
if (telemetry.local_ip) update('telLocalIp', telemetry.local_ip);
|
||||
// update() no-ops when the card is absent, which is the case for a v4-only panel — a screen that
|
||||
// acquires a v6 address mid-session picks the card up on the next full render, not this path.
|
||||
if (telemetry.local_ip6) update('telLocalIp6', telemetry.local_ip6);
|
||||
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
|
||||
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
|
||||
if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));
|
||||
|
|
@ -2104,6 +2356,12 @@ export function cleanup() {
|
|||
if (shellHandler) off('shell-result', shellHandler); // #161 owner-tools listener
|
||||
if (screenshotInterval) clearInterval(screenshotInterval);
|
||||
if (remoteActive && currentDevice) stopRemote(currentDevice.id);
|
||||
// Same reasoning as stopRemote above: an operator who navigates away has stopped watching, so
|
||||
// the display should stop talking. Must run BEFORE currentDevice is cleared.
|
||||
if (debugStreamOn && currentDevice) sendCommand(currentDevice.id, 'set_debug', { enabled: false });
|
||||
debugStreamOn = false;
|
||||
debugFrozen = false;
|
||||
debugHeld = [];
|
||||
remoteActive = false;
|
||||
currentDevice = null;
|
||||
window._sendKey = null;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function render(container) {
|
|||
{ icon: '📅', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] },
|
||||
{ icon: '🖥', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] },
|
||||
{ icon: '🖱', title: 'Kiosk/Touchscreen', steps: ['Go to Kiosk and create a new page', 'Add buttons with labels, icons, and actions', 'Configure the idle screen timeout', 'Preview the page in the editor', 'Assign to a device as a widget'] },
|
||||
{ icon: '🎬', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Set the grid size (e.g., 2x2)', 'Drag devices onto grid positions', 'Set bezel compensation if needed', 'Assign content to play across all displays'] },
|
||||
{ icon: '🎬', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Drag displays onto the canvas and arrange them to match the PHYSICAL wall', 'Panel hung sideways? Select it and set "How this panel is mounted" — no need to pre-rotate your video', 'Set bezel compensation if needed, then "Fit player to screens"', 'Assign a playlist to play across all displays', 'The Panels list below the canvas shows each screen\'s online state and links to its device page'] },
|
||||
].map(guide => `
|
||||
<div class="settings-section" style="margin:0">
|
||||
<h3 style="font-size:15px">${guide.icon} ${guide.title}</h3>
|
||||
|
|
@ -44,6 +44,7 @@ export function render(container) {
|
|||
{ q: 'Can I white-label the dashboard?', a: 'Yes! Go to Settings > White Label to customize the brand name, colors, logo, and domain.' },
|
||||
{ q: 'How do I export proof-of-play reports?', a: 'Go to Reports, set your date range and filters, then click "Export CSV".' },
|
||||
{ q: 'What is a video wall?', a: 'A video wall combines multiple displays into one large screen. For example, four TVs in a 2x2 grid showing one big image/video.' },
|
||||
{ q: 'How do I build a wall from portrait (sideways-mounted) panels?', a: 'Arrange the tiles on the wall canvas exactly as the panels are hung — side by side stays side by side. Then select each tile and set "How this panel is mounted" to match how it was turned. The player rotates the content for you, so you do not need a pre-rotated copy of your video. While a display is in a wall, this setting replaces its own Orientation.' },
|
||||
].map(faq => `
|
||||
<div style="border-bottom:1px solid var(--border);padding:12px 0">
|
||||
<div style="font-weight:600;font-size:14px;margin-bottom:4px">${faq.q}</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,33 @@
|
|||
import { showToast } from '../components/toast.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
|
||||
/*
|
||||
* A recognisable mark for the providers people expect to see, and an honest generic one for
|
||||
* everything else. Inline SVG rather than a remote image: an <img> to a provider CDN would put a
|
||||
* third-party origin back into the CSP, which is precisely what moving the flow server-side removed.
|
||||
*/
|
||||
const PROVIDER_ICONS = {
|
||||
google: `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>`,
|
||||
microsoft: `<svg width="18" height="18" viewBox="0 0 21 21" aria-hidden="true">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const GENERIC_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>`;
|
||||
|
||||
const providerIcon = (slug) => PROVIDER_ICONS[slug] || GENERIC_ICON;
|
||||
|
||||
let authConfig = null;
|
||||
|
||||
|
|
@ -77,8 +105,19 @@ export async function render(container) {
|
|||
<input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t('auth.password')}</label>
|
||||
<label id="loginPasswordLabel" for="loginPassword">${t('auth.password')}</label>
|
||||
<input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password">
|
||||
<!-- Filled in only when the typed email belongs to an organization that has configured
|
||||
its own identity provider. A customer's IdP is never listed to everyone: the button
|
||||
appears for the people it belongs to and nobody else, which also keeps the customer
|
||||
list off the login page.
|
||||
|
||||
⚠️ BELOW the input, inside the same group. Above it, the button sat between the
|
||||
"Password" label and its field — so the label described the SSO button and the
|
||||
password box had none at all. It has to stay INSIDE the group, because hiding the
|
||||
group is how the password is hidden and the button must survive that... which is
|
||||
exactly why setPasswordVisible() hides the FIELD, never the container. -->
|
||||
<div id="orgSsoSlot" style="display:none;margin-top:12px"></div>
|
||||
</div>
|
||||
${isSetup ? `
|
||||
<div class="form-group">
|
||||
|
|
@ -148,39 +187,32 @@ export async function render(container) {
|
|||
</div>
|
||||
|
||||
<div id="ssoBlock">
|
||||
${config.googleEnabled || config.microsoftEnabled ? `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
|
||||
${(config.providers || []).length ? `
|
||||
<div id="ssoDivider" style="display:flex;align-items:center;gap:12px;margin:20px 0">
|
||||
<hr style="flex:1;border-color:var(--border)">
|
||||
<span style="color:var(--text-muted);font-size:12px">${t('auth.divider_or')}</span>
|
||||
<hr style="flex:1;border-color:var(--border)">
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${config.googleEnabled ? `
|
||||
<div id="googleSignInContainer">
|
||||
<button class="btn btn-secondary" id="googleSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
${t('auth.signin_google')}
|
||||
</button>
|
||||
<!-- One button per configured provider, and each is a plain LINK to a server endpoint.
|
||||
There is no provider SDK on this page: the browser never speaks to the identity
|
||||
provider directly, so nothing here needs a client id and the CSP needs no
|
||||
third-party script origin. Google and Microsoft are ordinary entries in this list.
|
||||
The icon is chosen by slug where we have one and falls back to a generic mark, so a
|
||||
self-hoster's Keycloak or Authentik still gets a real-looking button. -->
|
||||
<!-- Wrapped so the whole set can be hidden at once: an organization that REQUIRES its own
|
||||
identity provider must not be shown the operator's, which are not domain-confined. -->
|
||||
<div id="instanceProviders">
|
||||
${(config.providers || []).map((p) => `
|
||||
<a class="btn btn-secondary" href="/api/auth/oidc/${encodeURIComponent(p.slug)}/start"
|
||||
id="sso-${esc(p.slug)}"
|
||||
style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px;text-decoration:none">
|
||||
${providerIcon(p.slug)}
|
||||
${esc(t('auth.signin_with', { provider: p.name }))}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${config.microsoftEnabled ? `
|
||||
<button class="btn btn-secondary" id="microsoftSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px">
|
||||
<svg width="18" height="18" viewBox="0 0 21 21">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>
|
||||
${t('auth.signin_microsoft')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -255,7 +287,15 @@ function setupHandlers(config, isSetup) {
|
|||
if (isSetup) {
|
||||
document.getElementById('loginBtn')?.addEventListener('click', () => doRegister(true));
|
||||
} else {
|
||||
document.getElementById('loginBtn')?.addEventListener('click', doLogin);
|
||||
/*
|
||||
* Identifier-first. The button is "Next" until an address has been submitted: we ask the server
|
||||
* what that address uses BEFORE offering a credential, so an SSO-only user is never shown a
|
||||
* password box that is going to be refused, and the org lookup has somewhere to happen.
|
||||
*/
|
||||
document.getElementById('loginBtn')?.addEventListener('click', () => {
|
||||
if (identified && !ssoOnlyDomain) return doLogin();
|
||||
identify();
|
||||
});
|
||||
document.getElementById('showRegisterBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('localAuthForm').style.display = 'none';
|
||||
document.getElementById('registerForm').style.display = 'block';
|
||||
|
|
@ -272,6 +312,40 @@ function setupHandlers(config, isSetup) {
|
|||
if (e.key === 'Enter') isSetup ? doRegister(true) : doLogin();
|
||||
});
|
||||
|
||||
/*
|
||||
* Enter in the EMAIL field advances rather than submitting. During first-run setup both fields
|
||||
* are needed at once, so identifier-first is skipped entirely there.
|
||||
*/
|
||||
document.getElementById('loginEmail')?.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (isSetup) return doRegister(true);
|
||||
if (identified && !ssoOnlyDomain) return doLogin();
|
||||
identify();
|
||||
});
|
||||
|
||||
/*
|
||||
* Editing the address after identifying returns to the identifier step. Someone who mistypes
|
||||
* their domain must get a fresh answer rather than keep the previous domain's one.
|
||||
*/
|
||||
document.getElementById('loginEmail')?.addEventListener('input', () => {
|
||||
if (!identified) return;
|
||||
identified = false;
|
||||
applyFormState();
|
||||
});
|
||||
|
||||
/*
|
||||
* Ask what this address uses, then show the right thing. The lookup itself sets ssoOnlyDomain via
|
||||
* setPasswordVisible(), so this only has to decide that we now know who is signing in.
|
||||
*/
|
||||
async function identify() {
|
||||
const email = document.getElementById('loginEmail').value.trim();
|
||||
if (!email || !email.includes('@')) { showError(t('auth.error_email_required')); return; }
|
||||
try { await lookupOrgSso(email); } catch { /* lookup failures fall through to the password box */ }
|
||||
identified = true;
|
||||
applyFormState();
|
||||
if (!ssoOnlyDomain) document.getElementById('loginPassword')?.focus();
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const email = document.getElementById('loginEmail').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
|
|
@ -284,6 +358,12 @@ function setupHandlers(config, isSetup) {
|
|||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
/*
|
||||
* The organization requires its identity provider, so this is not a credential failure and
|
||||
* must not read like one — "invalid password" sends the user to reset a password that will
|
||||
* never work again. Point them at the control that does work.
|
||||
*/
|
||||
if (!res.ok && data.code === 'sso_required') { showError(t('auth.sso_required')); return; }
|
||||
if (!res.ok) { showError(data.error); return; }
|
||||
// Unverified account (hosted hard-gate): no session — prompt to check email.
|
||||
if (data.verification_required) { showVerifyNotice(data.email || email); return; }
|
||||
|
|
@ -451,66 +531,247 @@ function setupHandlers(config, isSetup) {
|
|||
}
|
||||
}
|
||||
|
||||
// Google Sign-In
|
||||
if (config.googleEnabled) {
|
||||
document.getElementById('googleSignInBtn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
// Use Google's popup-based sign in
|
||||
const client = google.accounts.oauth2.initTokenClient({
|
||||
client_id: config.googleClientId,
|
||||
scope: 'email profile',
|
||||
callback: async (response) => {
|
||||
if (response.access_token) {
|
||||
// Get ID token via Google's tokeninfo
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${response.access_token}`);
|
||||
const tokenData = await tokenRes.json();
|
||||
// Send to our server
|
||||
const res = await fetch('/api/auth/google', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: response.access_token, email: tokenData.email })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) onAuthSuccess(data);
|
||||
else showError(data.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
client.requestAccessToken();
|
||||
} catch (err) {
|
||||
showError(t('auth.error_google_failed'));
|
||||
}
|
||||
});
|
||||
/*
|
||||
* SSO is a link, not a script.
|
||||
*
|
||||
* The buttons above are anchors to /api/auth/oidc/<slug>/start, so there is nothing to bind here
|
||||
* and no SDK to wait for. What DOES need handling is the trip back: the callback redirects to
|
||||
* #/login carrying either a session token or an error code.
|
||||
*
|
||||
* The token rides in the URL FRAGMENT, which browsers never send to servers and proxies never
|
||||
* log — and it is stripped from the address bar before anything else happens, so a shared screen
|
||||
* or a copied URL does not carry a live session.
|
||||
*/
|
||||
/*
|
||||
* Email-first SSO for organizations.
|
||||
*
|
||||
* Instance-wide providers are always on the page. An ORG provider is different — it belongs to
|
||||
* one customer — so it is fetched by domain once the address looks complete, and only then.
|
||||
*
|
||||
* Debounced because this fires while someone types, and the endpoint is rate limited; asking on
|
||||
* every keystroke would spend a user's whole budget before they finished their own address.
|
||||
*/
|
||||
let lastDomainAsked = '';
|
||||
const orgSlot = () => document.getElementById('orgSsoSlot');
|
||||
|
||||
/*
|
||||
* Show or hide the password half of the sign-in form.
|
||||
*
|
||||
* Presentation only — the server refuses a password for these accounts regardless. Restoring it
|
||||
* on every negative answer matters as much as hiding it: someone who types an SSO-only address,
|
||||
* then corrects it to their own, must get the password box back.
|
||||
*/
|
||||
/*
|
||||
* Password visibility has TWO independent drivers, and conflating them is how this got confusing:
|
||||
*
|
||||
* identified — identifier-first. The password box does not exist until an address has been
|
||||
* submitted, because until then we do not know whether this account uses a
|
||||
* password at all. This is what lets the org lookup happen before we offer the
|
||||
* wrong thing.
|
||||
* ssoOnlyDomain — the address belongs to an organization that REQUIRES its own provider. Then a
|
||||
* password box is not merely going to fail, it is the wrong thing to show.
|
||||
*
|
||||
* The field appears only when identified AND not SSO-only. Kept as one function so the two can
|
||||
* never disagree about what is on screen.
|
||||
*/
|
||||
let identified = false;
|
||||
let ssoOnlyDomain = false;
|
||||
|
||||
function applyFormState() {
|
||||
const showPassword = identified && !ssoOnlyDomain;
|
||||
const show = showPassword ? '' : 'none';
|
||||
/*
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
for (const id of ['loginPassword', 'loginPasswordLabel']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = show;
|
||||
}
|
||||
|
||||
/*
|
||||
* The primary button is "Next" until an address has been submitted, then "Sign in". One button
|
||||
* rather than two, so there is never a choice about which to press.
|
||||
*/
|
||||
const btn = document.getElementById('loginBtn');
|
||||
if (btn) btn.textContent = identified && !ssoOnlyDomain ? t('auth.sign_in') : t('auth.next');
|
||||
if (btn) btn.style.display = ssoOnlyDomain ? 'none' : '';
|
||||
|
||||
/*
|
||||
* The instance's own providers stay visible at ALL times, by explicit decision: they are the
|
||||
* operator's, they are offered to everyone, and the server refuses them for an SSO-only
|
||||
* organization anyway. (Previously they were hidden for such domains so the page would not
|
||||
* invite the bypass; the cost was a login page that changed shape while you typed.)
|
||||
*/
|
||||
|
||||
/*
|
||||
* "Create Account" and "Forgot your password?" DO go for an SSO-only domain: registration there
|
||||
* is refused by the server, and a password reset produces one that can never be used.
|
||||
*/
|
||||
const reg = document.getElementById('showRegisterBtn');
|
||||
if (reg) reg.style.display = ssoOnlyDomain ? 'none' : '';
|
||||
const forgot = document.getElementById('forgotLink');
|
||||
if (forgot) {
|
||||
const wrap = forgot.parentElement && forgot.parentElement.tagName === 'P' ? forgot.parentElement : forgot;
|
||||
wrap.style.display = ssoOnlyDomain ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Microsoft Sign-In
|
||||
if (config.microsoftEnabled) {
|
||||
document.getElementById('microsoftSignInBtn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const msalConfig = {
|
||||
auth: {
|
||||
clientId: config.microsoftClientId,
|
||||
authority: `https://login.microsoftonline.com/${config.microsoftTenantId}`,
|
||||
redirectUri: window.location.origin
|
||||
}
|
||||
};
|
||||
const msalInstance = new msal.PublicClientApplication(msalConfig);
|
||||
await msalInstance.initialize();
|
||||
const loginResponse = await msalInstance.loginPopup({ scopes: ['User.Read'] });
|
||||
if (loginResponse.accessToken) {
|
||||
const res = await fetch('/api/auth/microsoft', {
|
||||
// Kept for the org lookup below, which reasons about SSO-only rather than about identification.
|
||||
function setPasswordVisible(visible) {
|
||||
ssoOnlyDomain = !visible;
|
||||
applyFormState();
|
||||
}
|
||||
|
||||
async function lookupOrgSso(email) {
|
||||
const at = String(email || '').lastIndexOf('@');
|
||||
const domain = at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
|
||||
const slot = orgSlot();
|
||||
if (!slot) return;
|
||||
// Nothing to ask about until there is a domain with a dot in it.
|
||||
if (!domain || !domain.includes('.')) {
|
||||
slot.style.display = 'none'; slot.innerHTML = ''; lastDomainAsked = ''; setPasswordVisible(true); return;
|
||||
}
|
||||
if (domain === lastDomainAsked) return;
|
||||
try {
|
||||
const res = await fetch(`/api/auth/sso/discover?email=${encodeURIComponent(email)}`);
|
||||
/*
|
||||
* ⚠️ Check the STATUS, not just that a body parsed.
|
||||
*
|
||||
* The comment below has always said a tripped rate limit must not poison the domain — and it
|
||||
* did anyway, because a 429 body is perfectly valid JSON: res.json() resolved, `data.sso`
|
||||
* came back undefined, so the single sign-on button was hidden, the password box restored,
|
||||
* and `lastDomainAsked` recorded — permanently, for the life of the page. On an SSO-only
|
||||
* domain that is the worst possible outcome: the password box the user is then offered gets
|
||||
* 403, and the button they are told to use is not on the screen. Discover is 10/min per IP,
|
||||
* so a handful of colleagues behind one office address is enough to trigger it.
|
||||
*/
|
||||
if (!res.ok) throw new Error(`discover ${res.status}`);
|
||||
const data = await res.json();
|
||||
// Remembered only after a SUCCESSFUL answer.
|
||||
lastDomainAsked = domain;
|
||||
if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; setPasswordVisible(true); return; }
|
||||
/*
|
||||
* When the organization REQUIRES its identity provider, the password box is not merely going
|
||||
* to fail — it is the wrong thing to offer. Showing it invites someone to type a password,
|
||||
* be refused, and go and reset a password that will never work again. Hidden, not disabled,
|
||||
* so there is one obvious way forward.
|
||||
*/
|
||||
setPasswordVisible(!data.required);
|
||||
/*
|
||||
* A FORM, not a link, and a deliberately generic label.
|
||||
*
|
||||
* The lookup tells us only that this domain uses SSO — never which provider or whose it is,
|
||||
* because that would identify a customer to anyone who guessed a domain. The server does the
|
||||
* mapping again on submit, so the slug is never published to the page. POST keeps the address
|
||||
* out of the URL, browser history and any Referer the provider's page would send.
|
||||
*/
|
||||
/*
|
||||
* A BUTTON that fetches and then navigates — not a form that submits.
|
||||
*
|
||||
* The dashboard's CSP is `form-action 'self'`, and Chrome applies it across the whole
|
||||
* redirect chain, so a form POST that 302s on to the customer's identity provider was
|
||||
* ABORTED with nothing shown to the user at all. The provider origins cannot be allowlisted
|
||||
* because customers supply them. A script-initiated navigation is not covered by
|
||||
* form-action, so the page asks the server where to go and goes there.
|
||||
*
|
||||
* Styled secondary: "Sign In" is the primary action while a password still works, and two
|
||||
* identical blue buttons stacked one above the other sent people to their IdP by muscle
|
||||
* memory after typing a password.
|
||||
*/
|
||||
slot.innerHTML = `
|
||||
<button type="button" id="orgSsoBtn" class="btn ${data.required ? 'btn-primary' : 'btn-secondary'}"
|
||||
style="width:100%;justify-content:center;padding:10px">
|
||||
${t('auth.signin_sso')}
|
||||
</button>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:6px;text-align:center">
|
||||
${t('auth.sso_org_hint')}
|
||||
</div>`;
|
||||
slot.style.display = '';
|
||||
|
||||
const btn = slot.querySelector('#orgSsoBtn');
|
||||
if (btn) btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/auth/sso/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: loginResponse.accessToken })
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) onAuthSuccess(data);
|
||||
else showError(data.error);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !body.start_url) throw new Error(body.error || `start ${r.status}`);
|
||||
window.location.assign(body.start_url);
|
||||
} catch {
|
||||
btn.disabled = false;
|
||||
showError(t('auth.sso_err_provider_unavailable'));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t('auth.error_microsoft_failed'));
|
||||
});
|
||||
} catch {
|
||||
// A failed lookup must never block a password login — the form still works, and the password
|
||||
// box comes back rather than leaving someone staring at a form with no way to submit it.
|
||||
slot.style.display = 'none';
|
||||
slot.innerHTML = '';
|
||||
setPasswordVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The lookup now runs on SUBMIT (identify()), not on every keystroke.
|
||||
*
|
||||
* Identifier-first made the debounced version both redundant and wrong: redundant because nothing
|
||||
* is shown until an address is submitted anyway, and wrong because it would answer for a
|
||||
* half-typed domain and change the form under someone mid-address. It also spent a rate-limit
|
||||
* budget of 10/min per IP on people who had not finished typing — an office behind one address
|
||||
* could exhaust it without a single sign-in attempt.
|
||||
*
|
||||
* ⚠️ Applied HERE, after the `let identified` / `let ssoOnlyDomain` declarations above. Called any
|
||||
* earlier it would throw on the temporal dead zone, which on this page means a login form that
|
||||
* never renders.
|
||||
*/
|
||||
if (isSetup) identified = true; // first-run setup needs both fields at once
|
||||
applyFormState();
|
||||
|
||||
/*
|
||||
* Completing an SSO login.
|
||||
*
|
||||
* The callback no longer hands the session token back in the URL — that was a login-CSRF hole,
|
||||
* because a crafted link could install an ATTACKER'S token and quietly sign the victim into their
|
||||
* account. The server now leaves it in a one-shot httpOnly cookie and we exchange it here, which
|
||||
* a link cannot forge.
|
||||
*
|
||||
* Wrapped in an async IIFE because setupHandlers() is not async; `await` at this level is a
|
||||
* SyntaxError that takes the whole module graph down with it, since app.js imports this file
|
||||
* statically and there is no bundler to catch it first.
|
||||
*/
|
||||
const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || ''));
|
||||
const ssoReturning = ssoParams.get('sso') === '1';
|
||||
const ssoError = ssoParams.get('sso_error');
|
||||
|
||||
if (ssoReturning || ssoError) {
|
||||
// Keep any real query string; only the hash carried the SSO markers.
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search + '#/login');
|
||||
}
|
||||
|
||||
if (ssoReturning) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/sso/claim', { method: 'POST' });
|
||||
if (!res.ok) throw new Error('claim rejected');
|
||||
const data = await res.json();
|
||||
onAuthSuccess(data);
|
||||
} catch {
|
||||
showToast(t('auth.sso_failed'), 'error');
|
||||
}
|
||||
});
|
||||
})();
|
||||
} else if (ssoError) {
|
||||
// Every code the callback can emit has a message; an unknown one still says something true
|
||||
// rather than failing silently, which is how the previous implementation behaved on every click.
|
||||
const known = ['expired', 'bad_state', 'no_code', 'no_email', 'email_unverified',
|
||||
'verification_failed', 'provider_refused', 'provider_unavailable', 'unknown_provider',
|
||||
'registration_disabled', 'account_exists_local', 'subject_mismatch', 'server_error',
|
||||
'domain_not_allowed', 'account_exists_other_provider', 'sso_required'];
|
||||
const key = known.includes(ssoError) ? `auth.sso_err_${ssoError}` : 'auth.sso_failed';
|
||||
showToast(t(key), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,7 +219,10 @@ export function render(container) {
|
|||
await fetch(`/api/assignments/device/${pairedDeviceId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content_id: content.id, duration_sec: 10 })
|
||||
// #237: no duration_sec — onboarding never asked the operator for one, and a
|
||||
// hardcoded 10 cut their very first video off mid-play. Omitting it lets the
|
||||
// server default to the clip's own length (still 10s for a photo).
|
||||
body: JSON.stringify({ content_id: content.id })
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { api } from '../api.js';
|
|||
import { showToast } from '../components/toast.js';
|
||||
import { esc, hydrateAuthImages } from '../utils.js';
|
||||
import { t, tn } from '../i18n.js';
|
||||
import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js';
|
||||
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '--';
|
||||
|
|
@ -210,9 +211,13 @@ async function renderDetail(container, playlistId) {
|
|||
// /api/playlists/:id/preview-payload and renders with its unmodified renderer, so the
|
||||
// preview is byte-identical to what a device shows. Orientation toggle just reloads
|
||||
// the iframe with &orientation; the server passes it through.
|
||||
// #238: Portrait here had the same fault as the device preview — the iframe was given the
|
||||
// as-displayed 9/16 shape AND the player rotated inside it, so the portrait toggle showed sideways
|
||||
// content. The stage is the panel's face; the iframe is its landscape framebuffer, turned back by
|
||||
// the stand-in for the wall mount.
|
||||
function showPlaylistPreview(playlist) {
|
||||
let orientation = 'landscape';
|
||||
const aspect = () => (orientation.startsWith('portrait') ? '9 / 16' : '16 / 9');
|
||||
const aspect = () => displayAspectRatio(orientation);
|
||||
const frameSrc = () => `/player?preview=1&playlist=${encodeURIComponent(playlist.id)}&orientation=${orientation}&t=${Date.now()}`;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
|
||||
|
|
@ -227,28 +232,89 @@ function showPlaylistPreview(playlist) {
|
|||
</div>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
|
||||
<iframe id="pvpFrame" style="height:78vh;max-width:92vw;aspect-ratio:${aspect()};border:0;background:#000" src="${frameSrc()}"></iframe>
|
||||
<!-- The stage is the sized box; the frame inside it is rotated to the panel's own shape
|
||||
(#238), so a portrait preview is no longer the double rotation it used to be.
|
||||
72vh rather than 78: the modal caps at 92vh with overflow:hidden, and the header plus
|
||||
the transport row cost ~12vh — at 78 the skip buttons fell off the bottom. -->
|
||||
<div id="pvpStage" style="height:72vh;max-width:92vw;aspect-ratio:${aspect()};background:#000">
|
||||
<iframe id="pvpFrame" style="border:0;background:#000" src="${frameSrc()}"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;align-items:center;gap:12px;padding:10px 16px;border-top:1px solid var(--border)">
|
||||
<button class="btn btn-secondary btn-sm" id="pvpPrev" disabled>‹ ${t('playlist.preview_prev')}</button>
|
||||
<span id="pvpPosition" style="color:var(--text-muted);font-size:13px;min-width:110px;text-align:center"> </span>
|
||||
<button class="btn btn-secondary btn-sm" id="pvpNext" disabled>${t('playlist.preview_next')} ›</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
const stage = overlay.querySelector('#pvpStage');
|
||||
const frame = overlay.querySelector('#pvpFrame');
|
||||
frameDeviceOutput(stage, frame, orientation);
|
||||
const btnL = overlay.querySelector('#pvpLandscape');
|
||||
const btnP = overlay.querySelector('#pvpPortrait');
|
||||
const btnPrev = overlay.querySelector('#pvpPrev');
|
||||
const btnNext = overlay.querySelector('#pvpNext');
|
||||
const position = overlay.querySelector('#pvpPosition');
|
||||
|
||||
// #239: skip/next. The preview already IS the real player in device-free preview mode, so the
|
||||
// control is a message to that one iframe rather than a second copy of the playback logic.
|
||||
// Addressing frame.contentWindow (not a broadcast) and pinning targetOrigin to our own origin is
|
||||
// what keeps this off any real screen: a live display holds a socket to the server and is not
|
||||
// reachable from this page at all, and the preview player itself ignores the message unless it
|
||||
// booted with ?preview=1.
|
||||
const send = (action) => {
|
||||
try { frame.contentWindow?.postMessage({ source: 'screentinker-preview', action }, window.location.origin); } catch (e) {}
|
||||
};
|
||||
const onPlayerMessage = (ev) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
if (ev.source !== frame.contentWindow) return; // ignore any other frame on the page
|
||||
const d = ev.data;
|
||||
if (!d || d.source !== 'screentinker-player' || d.type !== 'preview:state') return;
|
||||
// A multi-zone playlist plays all zones at once, so there is no single item to step through —
|
||||
// showing a counter there would be a lie and the buttons would appear dead.
|
||||
if (d.zoned || !d.total) {
|
||||
btnPrev.disabled = btnNext.disabled = true;
|
||||
position.textContent = d.zoned ? t('playlist.preview_zoned') : '';
|
||||
return;
|
||||
}
|
||||
btnPrev.disabled = btnNext.disabled = false;
|
||||
position.textContent = t('playlist.preview_position', { current: (d.index >= 0 ? d.index : 0) + 1, total: d.total });
|
||||
};
|
||||
window.addEventListener('message', onPlayerMessage);
|
||||
// The player posts its state as soon as it has content, but an orientation reload restarts it —
|
||||
// ask again on every load so the counter can never be left stale from the previous run.
|
||||
frame.addEventListener('load', () => send('sync'));
|
||||
|
||||
const setOrientation = (o) => {
|
||||
orientation = o;
|
||||
frame.style.aspectRatio = aspect();
|
||||
// The stage carries the aspect; the frame is rotated inside it (#238).
|
||||
stage.style.aspectRatio = aspect();
|
||||
frameDeviceOutput(stage, frame, orientation);
|
||||
btnPrev.disabled = btnNext.disabled = true; // reloading: no item until the player says so
|
||||
position.textContent = '';
|
||||
frame.src = frameSrc();
|
||||
btnL.className = 'btn btn-sm ' + (o === 'landscape' ? 'btn-primary' : 'btn-secondary');
|
||||
btnP.className = 'btn btn-sm ' + (o.startsWith('portrait') ? 'btn-primary' : 'btn-secondary');
|
||||
};
|
||||
btnL.onclick = () => setOrientation('landscape');
|
||||
btnP.onclick = () => setOrientation('portrait');
|
||||
const close = () => overlay.remove();
|
||||
btnPrev.onclick = () => send('prev');
|
||||
btnNext.onclick = () => send('next');
|
||||
// Listeners are on window/document, so they outlive the overlay unless close() takes them with
|
||||
// it — a leaked keydown handler would keep firing at a closed preview.
|
||||
const close = () => {
|
||||
overlay.remove();
|
||||
window.removeEventListener('message', onPlayerMessage);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
function onKey(ev) {
|
||||
if (ev.key === 'Escape') close();
|
||||
else if (ev.key === 'ArrowRight') send('next');
|
||||
else if (ev.key === 'ArrowLeft') send('prev');
|
||||
}
|
||||
overlay.querySelector('#pvpClose').onclick = close;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
||||
document.addEventListener('keydown', function esc(ev) {
|
||||
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
|
||||
});
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -735,7 +801,12 @@ async function showAddItemModal(playlistId, opts = {}) {
|
|||
list.innerHTML = filtered.map(item => {
|
||||
const isWidget = activeTab === 'widgets';
|
||||
const name = item.filename || item.name || t('common.unknown');
|
||||
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : (item.mime_type || '');
|
||||
// #237: the server gives a video item the clip's own length instead of the 10s default.
|
||||
// Show that length here so the duration the item lands with is something the operator
|
||||
// saw coming, rather than a number that appears in the list after the fact.
|
||||
const clipSec = !isWidget && Number(item.duration_sec) > 0 ? Math.ceil(item.duration_sec) : 0;
|
||||
const clip = clipSec ? ` · ${Math.floor(clipSec / 60)}:${String(clipSec % 60).padStart(2, '0')}` : '';
|
||||
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : ((item.mime_type || '') + clip);
|
||||
const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null;
|
||||
return `
|
||||
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export async function render(container) {
|
|||
<div class="form-group" style="margin:0"><label>${t('report.device')}</label>
|
||||
<select id="reportDevice" class="input" style="width:200px;background:var(--bg-input)">
|
||||
<option value="">${t('report.all_devices')}</option>
|
||||
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
|
||||
${devices.map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0"><label>${t('report.start_date')}</label>
|
||||
|
|
@ -182,10 +182,10 @@ function renderBarChart(containerId, data) {
|
|||
const maxVal = Math.max(...data.map(d => d.value), 1);
|
||||
|
||||
container.innerHTML = data.map(d => `
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:0" title="${d.label}: ${d.value}">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:0" title="${esc(d.label)}: ${esc(d.value)}">
|
||||
<div style="font-size:9px;color:var(--text-muted);margin-bottom:2px;display:${d.value > 0 ? 'block' : 'none'}">${d.value}</div>
|
||||
<div style="width:100%;max-width:20px;height:${Math.max(2, (d.value / maxVal) * 160)}px;background:var(--accent);border-radius:2px 2px 0 0;min-height:2px"></div>
|
||||
<div style="font-size:8px;color:var(--text-muted);margin-top:4px;transform:rotate(-45deg);white-space:nowrap">${d.label}</div>
|
||||
<div style="font-size:8px;color:var(--text-muted);margin-top:4px;transform:rotate(-45deg);white-space:nowrap">${esc(d.label)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ export async function render(container) {
|
|||
// admin is now just isPlatformAdmin. (Elevated capability otherwise comes from
|
||||
// org/workspace membership, gated in the members views, not users.role.)
|
||||
const isAdmin = isSuperAdmin;
|
||||
const canManageOrgSecurity = isSuperAdmin || user.current_org_role === 'org_owner' || user.current_org_role === 'org_admin';
|
||||
const widgetIsolationDisabled = !!user.current_organization?.widget_sandbox_isolation_disabled;
|
||||
const WIDGET_ISOLATION_CONFIRM_PHRASE = 'I understand I am enabling a security hole';
|
||||
|
||||
// #83: the "About" version was hardcoded (showed v1.4.1 regardless of the build).
|
||||
// Read it from the server (/api/version) the same way the admin view does.
|
||||
|
|
@ -59,6 +62,16 @@ export async function render(container) {
|
|||
<p style="color:var(--text-muted);font-size:12px;margin-top:16px">${t('settings.sso_note', { provider: esc(user.auth_provider || 'SSO') })}</p>
|
||||
`}
|
||||
|
||||
<!--
|
||||
Sign-in method (#258). An account has exactly ONE credential: a password, or one
|
||||
instance-wide provider. Linking deletes the password; unlinking requires a new one in the
|
||||
same step, so the account is never briefly left with no way in. Populated by loadSsoLink().
|
||||
-->
|
||||
<div id="ssoLinkBlock" style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
|
||||
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.signin_method')}</h4>
|
||||
<p style="color:var(--text-muted);font-size:12px">…</p>
|
||||
</div>
|
||||
|
||||
<!-- Two-factor authentication (#100). Populated by load2FA() from /auth/totp/status. -->
|
||||
<div id="twoFactorBlock" style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
|
||||
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.2fa_title')}</h4>
|
||||
|
|
@ -66,6 +79,35 @@ export async function render(container) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-organization SSO. Hidden unless the signed-in user administers an organization: this
|
||||
is the most security-relevant setting a tenant has, so it is not shown to members who
|
||||
cannot change it. Instance-wide providers are the operator's business and are configured
|
||||
by environment, not here. -->
|
||||
<div class="settings-section" id="ssoCard" style="display:none">
|
||||
<h3>${t('sso.title')}</h3>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('sso.blurb')}</p>
|
||||
<div id="ssoList"></div>
|
||||
<details id="ssoAddDetails" style="margin-top:12px">
|
||||
<summary style="cursor:pointer;font-size:13px">${t('sso.add')}</summary>
|
||||
<div style="margin-top:12px;display:grid;gap:10px;max-width:560px">
|
||||
<div class="form-group"><label>${t('sso.f_name')}</label>
|
||||
<input type="text" id="ssoName" class="input" placeholder="Acme SSO"></div>
|
||||
<div class="form-group"><label>${t('sso.f_issuer')}</label>
|
||||
<input type="url" id="ssoIssuer" class="input" placeholder="https://login.example.com">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_issuer_hint')}</div></div>
|
||||
<div class="form-group"><label>${t('sso.f_client_id')}</label>
|
||||
<input type="text" id="ssoClientId" class="input"></div>
|
||||
<div class="form-group"><label>${t('sso.f_client_secret')}</label>
|
||||
<input type="password" id="ssoClientSecret" class="input" autocomplete="new-password">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_client_secret_hint')}</div></div>
|
||||
<div class="form-group"><label>${t('sso.f_domains')}</label>
|
||||
<input type="text" id="ssoDomains" class="input" placeholder="acme.com, acme.co.uk">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_domains_hint')}</div></div>
|
||||
<div><button class="btn btn-primary btn-sm" id="ssoCreateBtn">${t('sso.create')}</button></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>${t('apitoken.title')}</h3>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.desc')}</p>
|
||||
|
|
@ -103,12 +145,37 @@ export async function render(container) {
|
|||
<div id="tokenEditPanel" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
${canManageOrgSecurity ? `
|
||||
<div class="settings-section">
|
||||
<h3>Security</h3>
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap">
|
||||
<div style="min-width:260px;flex:1">
|
||||
<div style="font-weight:600">Widget sandbox isolation</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">
|
||||
Keep widget code in a null-origin sandbox. Turning this off allows widget code to run with same-origin access.
|
||||
</div>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;white-space:nowrap">
|
||||
<input type="checkbox" id="widgetSandboxIsolationToggle" ${widgetIsolationDisabled ? '' : 'checked'}>
|
||||
<span>${widgetIsolationDisabled ? 'Isolation disabled' : 'Isolation enabled'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${isAdmin ? `
|
||||
<div class="settings-section">
|
||||
<h3>${t('settings.license')}</h3>
|
||||
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
|
||||
</div>
|
||||
|
||||
${isSuperAdmin ? `
|
||||
<div class="settings-section" id="telemetrySection">
|
||||
<h3>Install statistics</h3>
|
||||
<div id="telemetryBody"><p style="color:var(--text-muted);font-size:13px">Loading…</p></div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${isSuperAdmin ? `<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('settings.platform_admin_link')} <a href="#/admin" style="color:var(--accent)">${t('nav.admin')}</a> ${t('settings.platform_admin_page_suffix')}</p>` : ''}
|
||||
|
||||
<div class="settings-section">
|
||||
|
|
@ -215,6 +282,7 @@ export async function render(container) {
|
|||
if (isAdmin) {
|
||||
loadUsers();
|
||||
loadWhiteLabel();
|
||||
loadTelemetry();
|
||||
|
||||
// Support token generator
|
||||
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
|
||||
|
|
@ -312,7 +380,7 @@ export async function render(container) {
|
|||
let html = t('settings.import.complete', { imported });
|
||||
if (result.device_pairings?.length) {
|
||||
html += `<br><br><strong>${t('settings.import.pairing_codes_title')}</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` +
|
||||
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
|
||||
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${esc(d.name)}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
|
||||
`</table><br>${t('settings.import.pairing_codes_hint')}`;
|
||||
}
|
||||
html += `<br><br>${(result.notes || []).map(n => '• ' + n).join('<br>')}`;
|
||||
|
|
@ -476,6 +544,176 @@ export async function render(container) {
|
|||
// ==================== Two-factor authentication (#100) ====================
|
||||
// Drives the merged TOTP backend (/api/auth/totp/*). Re-renders #twoFactorBlock
|
||||
// for each state: SSO note / disabled+enroll / recovery-codes / enabled+manage.
|
||||
/*
|
||||
* Sign-in method: password OR one instance-wide provider, never both.
|
||||
*
|
||||
* The warning on the link button is the whole UX: the local password is DELETED, not kept as a
|
||||
* fallback, and someone who does not read that will think they gained a second way in. Unlink
|
||||
* asks for the new password up front for the same reason — the account must never sit between
|
||||
* credentials.
|
||||
*
|
||||
* Only instance-wide providers appear. An organization's provider is chosen by a customer and
|
||||
* must not be attachable to a platform account; the server refuses it too.
|
||||
*/
|
||||
/*
|
||||
* Install statistics. Shows the ACTUAL payload rather than a description of it — the whole
|
||||
* proposition is "you can check instead of trusting us", and the code is public, so a sentence
|
||||
* that didn't match the bytes would be found. Also shows what was last really sent.
|
||||
*/
|
||||
async function loadTelemetry() {
|
||||
const box = document.getElementById('telemetryBody');
|
||||
if (!box) return;
|
||||
let info;
|
||||
try { info = await api.adminGetTelemetry(); }
|
||||
catch { box.innerHTML = `<p style="color:var(--text-muted);font-size:13px">Unavailable.</p>`; return; }
|
||||
|
||||
const on = info.state === 'on';
|
||||
const sent = info.last_report
|
||||
? `Last sent ${new Date(info.last_report.at * 1000).toLocaleString()}.`
|
||||
: 'Nothing has been sent yet.';
|
||||
|
||||
// A blocked outbound connection is the normal failure on a self-hosted box, and it is
|
||||
// otherwise invisible — the operator just sees nothing arriving. Name the failure and the
|
||||
// host, so the fix is "allow this in the firewall" rather than "guess".
|
||||
const failed = on && info.last_error;
|
||||
const why = failed
|
||||
? ({ network: 'the connection was refused or the address did not resolve',
|
||||
timeout: 'the connection timed out' }[info.last_error.reason]
|
||||
|| `the server replied ${esc(info.last_error.reason)}`)
|
||||
: '';
|
||||
|
||||
box.innerHTML = `
|
||||
<p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">
|
||||
ScreenTinker can't see how widely it's deployed, because most installs are private by
|
||||
design. Sharing lets us say how many screens are running — nothing more.
|
||||
</p>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<input type="checkbox" id="telemetryToggle" ${on ? 'checked' : ''}>
|
||||
Share install statistics
|
||||
</label>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:6px">
|
||||
Everything that would be sent, in full:
|
||||
</p>
|
||||
<pre style="background:var(--bg-input,rgba(0,0,0,.2));padding:10px;border-radius:var(--radius);font-size:12px;overflow-x:auto;margin-bottom:8px">${esc(JSON.stringify(info.payload, null, 2))}</pre>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:${info.extra_endpoint ? '4' : '8'}px">
|
||||
${on ? 'Sent once a day to' : 'When enabled, sent once a day to'}
|
||||
<code style="font-size:11px">${esc(info.endpoint || '')}</code>. If this server's outbound
|
||||
traffic is filtered, that address has to be allowed or the reports never arrive.
|
||||
</p>
|
||||
${info.extra_endpoint ? `
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">
|
||||
A second copy also goes to your own collector at
|
||||
<code style="font-size:11px">${esc(info.extra_endpoint)}</code>, configured on this server
|
||||
with <code style="font-size:11px">TELEMETRY_EXTRA_ENDPOINT</code>. That is in addition to
|
||||
the above, not instead of it — turn the switch off if you want your own statistics without
|
||||
sharing.
|
||||
</p>` : ''}
|
||||
${failed ? `
|
||||
<p style="font-size:12px;color:var(--danger);margin-bottom:8px">
|
||||
The last attempt (${esc(new Date(info.last_error.at * 1000).toLocaleString())}) did not get
|
||||
through — ${why}. Check that outbound HTTPS to that address is permitted.
|
||||
</p>` : ''}
|
||||
<p style="color:var(--text-muted);font-size:12px">
|
||||
No names, addresses, content, or user details. The ID is random and identifies the install
|
||||
only so repeat reports aren't counted twice. ${esc(sent)}
|
||||
</p>
|
||||
`;
|
||||
|
||||
document.getElementById('telemetryToggle')?.addEventListener('change', async (e) => {
|
||||
const enabled = e.target.checked;
|
||||
try {
|
||||
// Turning it on sends immediately, so a blocked firewall is reported here and now rather
|
||||
// than failing quietly tonight — say so plainly instead of a cheerful success toast.
|
||||
const r = await api.adminSetTelemetry(enabled);
|
||||
if (!enabled) showToast('Install statistics off', 'success');
|
||||
else if (r.first_report && r.first_report.sent) showToast('Shared — thank you', 'success');
|
||||
else showToast('Saved, but the first report did not get through — see below', 'error');
|
||||
loadTelemetry();
|
||||
} catch {
|
||||
e.target.checked = !enabled;
|
||||
showToast('Could not save that setting', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSsoLink() {
|
||||
const block = document.getElementById('ssoLinkBlock');
|
||||
if (!block) return;
|
||||
const head = `<h4 style="font-size:14px;margin-bottom:8px">${t('settings.signin_method')}</h4>`;
|
||||
const muted = 'color:var(--text-muted);font-size:12px';
|
||||
const paint = (inner) => { block.innerHTML = head + inner; };
|
||||
|
||||
let me;
|
||||
try { me = await api.getMe(); }
|
||||
catch (e) { paint(`<p style="${muted}">${esc(e.message)}</p>`); return; }
|
||||
|
||||
let providers = [];
|
||||
try {
|
||||
const res = await fetch('/api/auth/providers');
|
||||
if (res.ok) providers = (await res.json()).providers || [];
|
||||
} catch { /* offline: fall through to the no-providers copy */ }
|
||||
|
||||
if (me.auth_provider && me.auth_provider !== 'local') {
|
||||
const name = providers.find((p) => p.slug === me.auth_provider)?.name || me.auth_provider;
|
||||
paint(`
|
||||
<p style="${muted};margin-bottom:12px">${t('settings.signin_linked', { provider: esc(name) })}</p>
|
||||
<div id="unlinkForm" style="display:none;margin-bottom:12px">
|
||||
<p style="${muted};margin-bottom:8px">${t('settings.signin_unlink_desc')}</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
|
||||
<div class="form-group"><label>${t('settings.new_password')}</label><input type="password" id="unlinkPw" class="input" autocomplete="new-password"></div>
|
||||
<div class="form-group"><label>${t('settings.confirm_new_password')}</label><input type="password" id="unlinkPw2" class="input" autocomplete="new-password"></div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="unlinkConfirmBtn">${t('settings.signin_unlink_confirm')}</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="unlinkBtn">${t('settings.signin_unlink', { provider: esc(name) })}</button>
|
||||
`);
|
||||
document.getElementById('unlinkBtn').onclick = () => {
|
||||
document.getElementById('unlinkForm').style.display = '';
|
||||
document.getElementById('unlinkBtn').style.display = 'none';
|
||||
document.getElementById('unlinkPw').focus();
|
||||
};
|
||||
document.getElementById('unlinkConfirmBtn').onclick = async () => {
|
||||
const pw = document.getElementById('unlinkPw').value;
|
||||
const pw2 = document.getElementById('unlinkPw2').value;
|
||||
if (pw !== pw2) return showToast(t('settings.passwords_dont_match'), 'error');
|
||||
try {
|
||||
await api.ssoUnlink(pw);
|
||||
showToast(t('settings.signin_unlinked_toast'), 'success');
|
||||
loadSsoLink();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!providers.length) {
|
||||
paint(`<p style="${muted}">${t('settings.signin_password_only')}</p>`);
|
||||
return;
|
||||
}
|
||||
paint(`
|
||||
<p style="${muted};margin-bottom:12px">${t('settings.signin_password_now')}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
${providers.map((p) => `<button class="btn btn-secondary btn-sm" data-link-slug="${esc(p.slug)}">${t('settings.signin_link', { provider: esc(p.name) })}</button>`).join('')}
|
||||
</div>
|
||||
`);
|
||||
block.querySelectorAll('[data-link-slug]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const slug = btn.dataset.linkSlug;
|
||||
const name = providers.find((p) => p.slug === slug)?.name || slug;
|
||||
// Deliberately blunt: the password is destroyed, and that is the part people miss.
|
||||
if (!window.confirm(t('settings.signin_link_warning', { provider: name }))) return;
|
||||
/*
|
||||
* Fetch the authorize URL, then navigate to it. NOT location.href straight at the start
|
||||
* route: the session is a bearer token in localStorage, so a top-level navigation arrives
|
||||
* with no Authorization header and is refused as anonymous.
|
||||
*/
|
||||
try {
|
||||
const { url } = await api.ssoLinkStart(slug);
|
||||
window.location.href = url;
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function load2FA() {
|
||||
const block = document.getElementById('twoFactorBlock');
|
||||
if (!block) return;
|
||||
|
|
@ -610,6 +848,32 @@ export async function render(container) {
|
|||
|
||||
loadTokens();
|
||||
load2FA();
|
||||
loadSsoLink();
|
||||
|
||||
/*
|
||||
* Report the outcome of a link round trip.
|
||||
*
|
||||
* The callback returns to #/settings rather than the login page — an authenticated user bounced
|
||||
* to a login screen to be told "that did not work" reads as having been signed out. Params are
|
||||
* stripped afterwards so a refresh or a copied URL does not replay the message.
|
||||
*/
|
||||
(function reportLinkOutcome() {
|
||||
const q = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
const linked = q.get('sso_linked');
|
||||
const err = q.get('sso_error');
|
||||
if (!linked && !err) return;
|
||||
if (linked) {
|
||||
showToast(t('settings.signin_linked_toast', { provider: linked }), 'success');
|
||||
} else {
|
||||
const known = ['link_email_mismatch', 'link_already_used', 'not_linkable', 'no_email',
|
||||
'email_unverified', 'verification_failed', 'provider_unavailable', 'provider_refused',
|
||||
'unknown_provider', 'expired', 'bad_state', 'no_code', 'server_error'];
|
||||
const key = known.includes(err) ? `settings.signin_err_${err}` : 'auth.sso_failed';
|
||||
showToast(t(key), 'error');
|
||||
}
|
||||
history.replaceState(null, '', location.pathname + location.search + '#/settings');
|
||||
loadSsoLink();
|
||||
}());
|
||||
|
||||
// #73: agency scope reveals a playlist picker (the token's allowlist). Loaded lazily once.
|
||||
const tokScopeSel = document.getElementById('tokScope');
|
||||
|
|
@ -634,6 +898,388 @@ export async function render(container) {
|
|||
}
|
||||
});
|
||||
|
||||
/* ── Per-organization SSO ──────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Only an org owner/admin sees this. The server enforces the same rule (and answers 404, not
|
||||
* 403, so an outsider learns nothing) — this just avoids showing a card the user cannot use.
|
||||
*/
|
||||
const orgId = user.current_organization?.id;
|
||||
const canManageSso = orgId && ['org_owner', 'org_admin'].includes(user.current_org_role);
|
||||
|
||||
async function loadSso() {
|
||||
const card = document.getElementById('ssoCard');
|
||||
if (!card || !canManageSso) return;
|
||||
card.style.display = '';
|
||||
const listEl = document.getElementById('ssoList');
|
||||
let providers = [];
|
||||
try {
|
||||
const res = await fetch(`/api/organizations/${orgId}/sso`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('load failed');
|
||||
providers = (await res.json()).providers || [];
|
||||
} catch {
|
||||
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.load_failed'))}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!providers.length) {
|
||||
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.none'))}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Requiring SSO is a separate decision from having it, so it gets its own block rather than
|
||||
// hiding inside a provider — an organization may have several providers and one answer.
|
||||
let onlyState = null;
|
||||
try {
|
||||
const r = await fetch(`/api/organizations/${orgId}/sso-only`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (r.ok) onlyState = await r.json();
|
||||
} catch { /* the providers still render; the toggle simply does not appear */ }
|
||||
|
||||
const origin = `${window.location.protocol}//${window.location.host}`;
|
||||
listEl.innerHTML = providers.map((p) => `
|
||||
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<div>
|
||||
<strong>${esc(p.name)}</strong>
|
||||
${p.enabled ? '' : `<span style="font-size:11px;color:var(--text-muted)"> — ${esc(t('sso.disabled'))}</span>`}
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(p.issuer)}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted)">${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}</div>
|
||||
${((p.domains || []).some((d) => !d.verified) || (p.domains || []).length === 0)
|
||||
? `<div style="font-size:12px;color:var(--warning,#b45309);margin-top:2px">⚠️ ${esc(t('sso.unverified_warning'))}</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<!-- wrap, do not shrink-to-clip: at 375px this row ran to x=417 on a 375px viewport and
|
||||
the page does not scroll horizontally, so "Remove" was simply unreachable. -->
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">
|
||||
<button class="btn btn-secondary btn-sm" data-sso-test="${esc(p.id)}">${esc(t('sso.test'))}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-sso-edit="${esc(p.id)}">${esc(t('sso.edit'))}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-sso-toggle="${esc(p.id)}" data-enabled="${p.enabled ? '1' : '0'}">
|
||||
${esc(p.enabled ? t('sso.disable') : t('sso.enable'))}
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" data-sso-delete="${esc(p.id)}">${esc(t('sso.delete'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The admin has to paste this into their identity provider, and it must match character
|
||||
for character, so it is shown rather than described. -->
|
||||
<div style="margin-top:8px;font-size:12px">
|
||||
<div style="color:var(--text-muted)">${esc(t('sso.callback_label'))}</div>
|
||||
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px">${esc(origin + p.callback_url)}</code>
|
||||
</div>
|
||||
|
||||
<!-- Editing is per provider, because an organization may have several (one per domain, or
|
||||
one per identity provider after a merger) and they are configured independently. -->
|
||||
<!-- Domain proof. A claimed domain routes NOBODY until DNS confirms the organization
|
||||
controls it, so the state of each one is shown plainly rather than left to be inferred
|
||||
from a login that silently does not work. -->
|
||||
${(p.domains || []).length ? `
|
||||
<div style="margin-top:10px;font-size:12px">
|
||||
<div style="color:var(--text-muted);margin-bottom:4px">${esc(t('sso.domains_heading'))}</div>
|
||||
${p.domains.map((d, di) => `
|
||||
<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||||
<div><strong>${esc(d.domain)}</strong>
|
||||
${d.verified
|
||||
? `<span style="color:var(--success,#15803d)"> — ${esc(t('sso.domain_verified'))}</span>`
|
||||
: `<span style="color:var(--warning,#b45309)"> — ${esc(t('sso.domain_pending'))}</span>`}
|
||||
</div>
|
||||
${d.verified ? '' : `<button class="btn btn-secondary btn-sm" data-sso-verify="${esc(p.id)}" data-domain="${esc(d.domain)}" data-di="${di}">${esc(t('sso.verify_now'))}</button>`}
|
||||
</div>
|
||||
${d.verified ? '' : `
|
||||
<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.dns_instructions'))}</div>
|
||||
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px;margin-top:4px">${esc(d.record_name)} TXT ${esc(d.txt_value)}</code>
|
||||
`}
|
||||
<!-- ONE place for the outcome. The last failure is persisted server-side and was
|
||||
rendered here, while the click handler wrote the live result into a second
|
||||
element below it — so retrying showed the identical sentence twice, in two
|
||||
different colours. The handler replaces this element's text instead. -->
|
||||
<div id="ssoVerify-${esc(p.id)}-${di}" style="margin-top:4px;color:var(--danger,#b91c1c)">${d.verified ? '' : esc(d.last_error || '')}</div>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
<div id="ssoTest-${esc(p.id)}" style="display:none;margin-top:8px;font-size:12px"></div>
|
||||
<div id="ssoEdit-${esc(p.id)}" style="display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);display:none">
|
||||
<div style="display:grid;gap:10px;max-width:560px">
|
||||
<div class="form-group"><label>${esc(t('sso.f_name'))}</label>
|
||||
<input type="text" class="input" data-f="name" value="${esc(p.name)}"></div>
|
||||
<div class="form-group"><label>${esc(t('sso.f_issuer'))}</label>
|
||||
<input type="url" class="input" data-f="issuer" value="${esc(p.issuer)}"></div>
|
||||
<div class="form-group"><label>${esc(t('sso.f_client_id'))}</label>
|
||||
<input type="text" class="input" data-f="client_id" value="${esc(p.client_id)}"></div>
|
||||
<div class="form-group"><label>${esc(t('sso.f_client_secret'))}</label>
|
||||
<input type="password" class="input" data-f="client_secret" autocomplete="new-password"
|
||||
placeholder="${esc(p.has_client_secret ? t('sso.secret_set') : t('sso.secret_none'))}">
|
||||
<!-- A secret can never be shown back: the API does not return it. Blank therefore means
|
||||
"leave it alone" rather than "clear it", which is what stops a save from silently
|
||||
wiping a working configuration. Clearing is a separate, explicit choice. -->
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(t('sso.secret_edit_hint'))}</div>
|
||||
${p.has_client_secret ? `
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-top:6px">
|
||||
<input type="checkbox" data-f="clear_secret"> ${esc(t('sso.secret_clear'))}
|
||||
</label>` : ''}
|
||||
</div>
|
||||
<div class="form-group"><label>${esc(t('sso.f_domains'))}</label>
|
||||
<input type="text" class="input" data-f="email_domains" value="${esc(p.email_domains)}"></div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-primary btn-sm" data-sso-save="${esc(p.id)}">${esc(t('sso.save'))}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-sso-cancel="${esc(p.id)}">${esc(t('sso.cancel'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' });
|
||||
});
|
||||
});
|
||||
/*
|
||||
* Ask the server to look for the DNS record now. Pull-based on purpose: the admin has just
|
||||
* edited DNS and wants an answer, and a failure has to say WHICH failure — not published yet,
|
||||
* published wrong, or the claim expired and the record has changed underneath them.
|
||||
*/
|
||||
if (onlyState) {
|
||||
const pend = onlyState.pending_removal_request;
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = 'border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-top:4px';
|
||||
box.innerHTML = `
|
||||
<div style="font-weight:600;margin-bottom:4px">${esc(t('sso.only_heading'))}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${esc(t('sso.only_help'))}</div>
|
||||
${onlyState.sso_only ? `
|
||||
<div style="font-size:13px;margin-bottom:8px">✅ ${esc(t('sso.only_on'))}</div>
|
||||
${pend
|
||||
? `<div style="font-size:12px;color:var(--warning,#b45309)">⏳ ${esc(t('sso.only_pending'))}</div>
|
||||
<button class="btn btn-secondary btn-sm" id="ssoOnlyCancel" data-req="${esc(pend.id)}" style="margin-top:6px">${esc(t('sso.only_cancel'))}</button>`
|
||||
: `<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">${esc(t('sso.only_remove_help'))}</div>
|
||||
<button class="btn btn-secondary btn-sm" id="ssoOnlyRequest">${esc(t('sso.only_request'))}</button>`}
|
||||
` : `
|
||||
<div style="font-size:13px;margin-bottom:8px">${esc(t('sso.only_off'))}</div>
|
||||
${onlyState.verified_domains
|
||||
? `<button class="btn btn-secondary btn-sm" id="ssoOnlyEnable">${esc(t('sso.only_enable'))}</button>`
|
||||
: `<div style="font-size:12px;color:var(--warning,#b45309)">⚠️ ${esc(t('sso.only_needs_domain'))}</div>`}
|
||||
`}`;
|
||||
listEl.appendChild(box);
|
||||
|
||||
const post = async (url, body, method = 'POST') => {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) { showToast(j.error || t('sso.only_failed'), 'error'); return null; }
|
||||
return j;
|
||||
};
|
||||
|
||||
const enableBtn = box.querySelector('#ssoOnlyEnable');
|
||||
if (enableBtn) enableBtn.addEventListener('click', async () => {
|
||||
// Confirmed, because it removes the only way in for everyone at these domains, and the way
|
||||
// back needs the operator rather than this button.
|
||||
if (!window.confirm(t('sso.only_confirm'))) return;
|
||||
const r = await post(`/api/organizations/${orgId}/sso-only`);
|
||||
if (r) {
|
||||
showToast(t('sso.only_on'), 'success');
|
||||
/*
|
||||
* Name the people who just lost their only way in. The server reports them precisely so
|
||||
* the admin finds out HERE rather than from a support ticket — and it was being thrown
|
||||
* away, which made the whole warning pointless.
|
||||
*/
|
||||
const stranded = r.stranded_members || [];
|
||||
if (stranded.length) {
|
||||
window.alert(t('sso.only_stranded', { list: stranded.join('\n') }));
|
||||
}
|
||||
await loadSso();
|
||||
}
|
||||
});
|
||||
|
||||
const reqBtn = box.querySelector('#ssoOnlyRequest');
|
||||
if (reqBtn) reqBtn.addEventListener('click', async () => {
|
||||
const reason = window.prompt(t('sso.only_reason_prompt')) || '';
|
||||
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request`, { reason });
|
||||
if (r) { showToast(t('sso.only_requested'), 'success'); await loadSso(); }
|
||||
});
|
||||
|
||||
const cancelBtn = box.querySelector('#ssoOnlyCancel');
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', async () => {
|
||||
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request/${cancelBtn.dataset.req}`, null, 'DELETE');
|
||||
if (r) { showToast(t('sso.only_cancelled'), 'success'); await loadSso(); }
|
||||
});
|
||||
}
|
||||
|
||||
listEl.querySelectorAll('[data-sso-verify]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.ssoVerify;
|
||||
const domain = btn.dataset.domain;
|
||||
// Indexed, not derived from the domain: `a.b.test` and `a-b.test` both slugify to
|
||||
// `a-b-test`, and getElementById would put one domain's answer in the other's box.
|
||||
const out = document.getElementById(`ssoVerify-${id}-${btn.dataset.di}`);
|
||||
btn.disabled = true;
|
||||
if (out) { out.style.color = 'var(--text-muted)'; out.textContent = t('sso.verifying'); }
|
||||
try {
|
||||
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/domains/${encodeURIComponent(domain)}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (body.ok) {
|
||||
showToast(t('sso.domain_verified_toast', { domain }), 'success');
|
||||
await loadSso(); // re-render: the domain now routes, and the card must say so
|
||||
return;
|
||||
}
|
||||
// An expired claim has already been reissued server-side, so the records on screen are
|
||||
// stale — reload rather than leaving the admin publishing a value that no longer matches.
|
||||
if (body.expired) {
|
||||
showToast(body.error || t('sso.verify_failed'), 'error');
|
||||
await loadSso();
|
||||
return;
|
||||
}
|
||||
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = body.error || t('sso.verify_failed'); }
|
||||
} catch {
|
||||
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = t('sso.verify_failed'); }
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('[data-sso-test]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.ssoTest;
|
||||
const out = document.getElementById(`ssoTest-${id}`);
|
||||
if (!out) return;
|
||||
out.style.display = '';
|
||||
out.textContent = t('sso.testing');
|
||||
try {
|
||||
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/test`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { out.textContent = data.error || t('sso.test_failed'); return; }
|
||||
/*
|
||||
* Literal keys, never a key built by concatenating a check name. Doing that defeats the
|
||||
* check in server/test/i18n-keys-exist.js that every key an operator can see is
|
||||
* translated — and a check name the UI does not know would render as raw key text. The
|
||||
* fallback keeps an unknown one readable instead.
|
||||
*/
|
||||
const CHECK_LABELS = {
|
||||
discovery: t('sso.check_discovery'),
|
||||
endpoints: t('sso.check_endpoints'),
|
||||
signing_keys: t('sso.check_signing_keys'),
|
||||
};
|
||||
const rows = (data.checks || []).map((c) => `
|
||||
<div>${c.ok ? '✅' : '❌'} ${esc(CHECK_LABELS[c.name] || c.name)} — <span style="color:var(--text-muted)">${esc(c.detail || '')}</span></div>`).join('');
|
||||
/*
|
||||
* The caveat is shown on SUCCESS, not tucked away. Discovery and keys prove the provider
|
||||
* exists and that we could verify a token it signs — they say nothing about whether the
|
||||
* client id, the secret, or the redirect URI registration are right. A green tick that
|
||||
* implied "SSO works" would send an admin away from the one thing still to check.
|
||||
*/
|
||||
out.innerHTML = rows + (data.ok
|
||||
? `<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.test_caveat'))}</div>`
|
||||
: '');
|
||||
} catch {
|
||||
out.textContent = t('sso.test_failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('[data-sso-edit]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoEdit}`);
|
||||
if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('[data-sso-cancel]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoCancel}`);
|
||||
if (panel) panel.style.display = 'none';
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('[data-sso-save]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoSave}`);
|
||||
if (!panel) return;
|
||||
const val = (f) => panel.querySelector(`[data-f="${f}"]`)?.value?.trim() ?? '';
|
||||
const body = {
|
||||
name: val('name'),
|
||||
issuer: val('issuer'),
|
||||
client_id: val('client_id'),
|
||||
email_domains: val('email_domains'),
|
||||
};
|
||||
/*
|
||||
* Three states, and only these three:
|
||||
* typed a value -> replace the secret
|
||||
* ticked "remove" -> send '' so the server clears it
|
||||
* left blank, unticked -> send NOTHING, so the stored secret survives
|
||||
* Sending '' on every save is the bug this shape exists to avoid.
|
||||
*/
|
||||
const typed = panel.querySelector('[data-f="client_secret"]')?.value || '';
|
||||
const clearing = panel.querySelector('[data-f="clear_secret"]')?.checked;
|
||||
if (typed) body.client_secret = typed;
|
||||
else if (clearing) body.client_secret = '';
|
||||
|
||||
if (!body.name || !body.issuer || !body.client_id) {
|
||||
showToast(t('sso.missing_fields'), 'error');
|
||||
return;
|
||||
}
|
||||
await ssoRequest('PUT', `/${btn.dataset.ssoSave}`, body);
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('[data-sso-delete]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('sso.confirm_delete'))) return;
|
||||
await ssoRequest('DELETE', `/${btn.dataset.ssoDelete}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ssoRequest(method, path = '', body) {
|
||||
try {
|
||||
const res = await fetch(`/api/organizations/${orgId}/sso${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// The server's message is the useful one here — a bad issuer or a domain already claimed by
|
||||
// another organization both say exactly what went wrong, and a generic failure would not.
|
||||
if (!res.ok) { showToast(data.error || t('sso.save_failed'), 'error'); return false; }
|
||||
// "Saved" for a DELETE read as though nothing had been destroyed.
|
||||
showToast(t(method === 'DELETE' ? 'sso.removed' : 'sso.saved'), 'success');
|
||||
await loadSso();
|
||||
return true;
|
||||
} catch {
|
||||
showToast(t('sso.save_failed'), 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('ssoCreateBtn')?.addEventListener('click', async () => {
|
||||
const payload = {
|
||||
name: document.getElementById('ssoName').value.trim(),
|
||||
issuer: document.getElementById('ssoIssuer').value.trim(),
|
||||
client_id: document.getElementById('ssoClientId').value.trim(),
|
||||
client_secret: document.getElementById('ssoClientSecret').value,
|
||||
email_domains: document.getElementById('ssoDomains').value.trim(),
|
||||
};
|
||||
if (!payload.name || !payload.issuer || !payload.client_id) {
|
||||
showToast(t('sso.missing_fields'), 'error');
|
||||
return;
|
||||
}
|
||||
if (await ssoRequest('POST', '', payload)) {
|
||||
['ssoName', 'ssoIssuer', 'ssoClientId', 'ssoClientSecret', 'ssoDomains']
|
||||
.forEach((id) => { document.getElementById(id).value = ''; });
|
||||
document.getElementById('ssoAddDetails').open = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadSso();
|
||||
|
||||
|
||||
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('tokName').value.trim();
|
||||
const scope = document.getElementById('tokScope').value;
|
||||
|
|
@ -737,6 +1383,117 @@ export async function render(container) {
|
|||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('widgetSandboxIsolationToggle')?.addEventListener('change', async (e) => {
|
||||
const checkbox = e.currentTarget;
|
||||
const shouldEnableIsolation = !!checkbox.checked;
|
||||
const workspaceId = user.current_workspace_id;
|
||||
if (!workspaceId) {
|
||||
checkbox.checked = !shouldEnableIsolation;
|
||||
showToast('No active workspace', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldEnableIsolation) {
|
||||
const confirmed = await openWidgetSandboxDisableConfirmModal(WIDGET_ISOLATION_CONFIRM_PHRASE);
|
||||
if (!confirmed) {
|
||||
checkbox.checked = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.updateWorkspaceSecuritySettings(workspaceId, {
|
||||
widgetSandboxIsolationDisabled: true,
|
||||
confirmationPhrase: WIDGET_ISOLATION_CONFIRM_PHRASE,
|
||||
});
|
||||
const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 1 } };
|
||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||
showToast('Widget sandbox isolation disabled', 'success');
|
||||
} catch (err) {
|
||||
checkbox.checked = true;
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateWorkspaceSecuritySettings(workspaceId, { widgetSandboxIsolationDisabled: false });
|
||||
const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 0 } };
|
||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||
showToast('Widget sandbox isolation enabled', 'success');
|
||||
} catch (err) {
|
||||
checkbox.checked = false;
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openWidgetSandboxDisableConfirmModal(confirmationPhrase) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="width:min(760px,96vw)">
|
||||
<div class="modal-header"><h3>Disable widget sandbox isolation for this organization</h3></div>
|
||||
<div class="modal-body" style="white-space:pre-wrap;line-height:1.45">
|
||||
Widget HTML currently runs in a null-origin sandbox. That means widget code
|
||||
cannot read your session, your cookies, or anything else stored by
|
||||
ScreenTinker in this browser.
|
||||
|
||||
Turning this off re-enables allow-same-origin. Widget HTML will then run with
|
||||
the same privileges as ScreenTinker itself. Any script in any widget in this
|
||||
organization will be able to:
|
||||
|
||||
- Read the device token of every display that shows the widget, and act as
|
||||
that display against the ScreenTinker API
|
||||
- Read the session token of any logged-in user who opens a display in their
|
||||
own browser
|
||||
- Call the ScreenTinker API as that user, including admin actions
|
||||
- Read and modify content on every other display in this organization
|
||||
- Silently exfiltrate all of the above to any server it likes
|
||||
|
||||
The widget editor's Preview is NOT affected: it renders inside the dashboard,
|
||||
where your session lives, so it stays isolated whatever this setting says. A
|
||||
widget may therefore behave differently in Preview than on a display.
|
||||
|
||||
Because allow-scripts is also required for widgets to function, a widget can
|
||||
remove its own sandbox entirely once same-origin is granted. There is no
|
||||
partial protection left after this point.
|
||||
|
||||
Only enable this if every widget source in this organization is code you
|
||||
wrote, or code from a party you would trust with your admin password. A single
|
||||
compromised third-party embed, CDN, or ad tag is enough.
|
||||
|
||||
This setting applies to ALL widgets in this organization and cannot be scoped
|
||||
per display.
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<label for="widgetSandboxConfirmInput">Type the phrase below to confirm:</label>
|
||||
<div style="margin:6px 0 8px;font-weight:600">${esc(confirmationPhrase)}</div>
|
||||
<input id="widgetSandboxConfirmInput" type="text" class="input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="widgetSandboxConfirmCancel">Cancel</button>
|
||||
<button class="btn btn-danger" id="widgetSandboxConfirmSubmit" disabled>Disable isolation</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = overlay.querySelector('#widgetSandboxConfirmInput');
|
||||
const submit = overlay.querySelector('#widgetSandboxConfirmSubmit');
|
||||
const close = (ok) => {
|
||||
overlay.remove();
|
||||
resolve(ok);
|
||||
};
|
||||
const updateEnabled = () => {
|
||||
submit.disabled = input.value.trim() !== confirmationPhrase;
|
||||
};
|
||||
input.addEventListener('input', updateEnabled);
|
||||
overlay.querySelector('#widgetSandboxConfirmCancel').addEventListener('click', () => close(false));
|
||||
submit.addEventListener('click', () => close(true));
|
||||
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWhiteLabel() {
|
||||
|
|
@ -830,25 +1587,30 @@ async function loadUsers() {
|
|||
</thead>
|
||||
<tbody>
|
||||
${users.map(u => `
|
||||
<tr style="border-bottom:1px solid var(--border)" data-user-id="${u.id}">
|
||||
<!-- ESCAPED. A SECOND copy of the platform users table lives here, rendered from the
|
||||
same endpoint as the one in views/admin.js. Escaping only that one left this whole
|
||||
table wide open, including a raw text node for the email - and an org or workspace
|
||||
admin can choose an email, so this executed in the platform admin's session. When
|
||||
you touch one of these tables, touch both. -->
|
||||
<tr style="border-bottom:1px solid var(--border)" data-user-id="${esc(u.id)}">
|
||||
<td style="padding:10px 12px">
|
||||
<div style="font-weight:500">${u.name || u.email}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted)">${u.email}</div>
|
||||
<div style="font-weight:500">${esc(u.name || u.email)}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted)">${esc(u.email)}</div>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span>
|
||||
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${esc(u.auth_provider)}</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="color:${isPlatformAdmin(u) ? 'var(--accent)' : 'var(--text-secondary)'}">${u.role}</span>
|
||||
<span style="color:${isPlatformAdmin(u) ? 'var(--accent)' : 'var(--text-secondary)'}">${esc(u.role)}</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<select class="input plan-select" data-user-id="${u.id}" style="padding:4px 8px;font-size:12px;width:auto">
|
||||
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
|
||||
<select class="input plan-select" data-user-id="${esc(u.id)}" style="padding:4px 8px;font-size:12px;width:auto">
|
||||
${plans.map(p => `<option value="${esc(p.id)}" ${u.plan_id === p.id ? 'selected' : ''}>${esc(p.display_name)}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td style="padding:10px 12px;white-space:nowrap">
|
||||
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
|
||||
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
|
||||
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${esc(u.id)}" data-user-email="${esc(u.email)}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
|
||||
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${esc(u.id)}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { api } from '../api.js';
|
||||
import { on, off, requestScreenshot } from '../socket.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
import { esc, livenessBadge } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, {
|
||||
|
|
@ -17,6 +18,25 @@ const CANVAS_MIN_W = 1200;
|
|||
const CANVAS_MIN_H = 700;
|
||||
const CANVAS_PADDING = 200; // extra room beyond bounding box, in canvas units
|
||||
|
||||
// #236: how far a panel's own image has to be turned to come out upright on the wall — i.e. how
|
||||
// far the panel itself is hung the other way. Degrees CLOCKWISE, matching the per-device
|
||||
// orientation setting; the render rule lives in server/lib/wall-geometry.js.
|
||||
//
|
||||
// Before this existed the canvas was secretly FRAMEBUFFER space, so a customer with two portrait
|
||||
// panels side by side had to stack them vertically here and pre-rotate every video. The canvas is
|
||||
// now what it always looked like: the wall as the audience sees it.
|
||||
const WALL_ROTATIONS = [0, 90, 180, 270];
|
||||
const ROTATION_LABELS = { 0: 'Normal (0°)', 90: 'Turned left (90°)', 180: 'Upside down (180°)', 270: 'Turned right (270°)' };
|
||||
// A panel already configured portrait is already hung sideways; carry that across so the operator
|
||||
// doesn't have to say the same thing twice (and so the tile lands the right shape first time).
|
||||
const ORIENTATION_TO_ROTATION = { 'landscape': 0, 'portrait': 90, 'landscape-flipped': 180, 'portrait-flipped': 270 };
|
||||
|
||||
// Mirrors rotatedFootprint() in server/lib/wall-geometry.js — kept tiny and local because the
|
||||
// dashboard has no import path to the server lib.
|
||||
function footprintFor(renderW, renderH, rotation) {
|
||||
return (rotation === 90 || rotation === 270) ? { w: renderH, h: renderW } : { w: renderW, h: renderH };
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/wall/')) {
|
||||
|
|
@ -67,7 +87,7 @@ async function renderList(container) {
|
|||
</div>
|
||||
</div>
|
||||
<div class="content-item-body">
|
||||
<div class="content-item-name">${w.name}</div>
|
||||
<div class="content-item-name">${esc(w.name)}</div>
|
||||
<div class="content-item-size">${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -105,6 +125,14 @@ async function renderWallEditor(container, wallId) {
|
|||
if (d && d.render_width > 0 && d.render_height > 0) return { w: d.render_width, h: d.render_height };
|
||||
return { w: DEFAULT_SCREEN_W, h: DEFAULT_SCREEN_H };
|
||||
};
|
||||
// #236: the tile is the panel's footprint ON THE WALL, so a sideways-hung panel is a tall tile.
|
||||
const footprintOnWall = (id, rotation) => {
|
||||
const r = renderSizeFor(id);
|
||||
return footprintFor(r.w, r.h, rotation);
|
||||
};
|
||||
// A device already set to portrait is already hung sideways — start it there rather than making
|
||||
// the operator discover the rotation control after the wall comes out wrong.
|
||||
const defaultRotationFor = (id) => ORIENTATION_TO_ROTATION[deviceById(id)?.orientation] || 0;
|
||||
// When the panel's physical resolution differs from what it renders (rotated mount:
|
||||
// the box reports 800x1332 but draws 1332x800), the tile size can look "wrong". Return a
|
||||
// short note making explicit that the tile is sized to the RENDER surface, not the panel.
|
||||
|
|
@ -127,8 +155,10 @@ async function renderWallEditor(container, wallId) {
|
|||
rotation: d.rotation || 0,
|
||||
x: d.canvas_x ?? (d.grid_col * (baseW + bezelH)),
|
||||
y: d.canvas_y ?? (d.grid_row * (baseH + bezelV)),
|
||||
w: d.canvas_width ?? renderSizeFor(d.device_id).w,
|
||||
h: d.canvas_height ?? renderSizeFor(d.device_id).h,
|
||||
// Backfill sizes as the panel's footprint at its saved rotation. Every wall in the field is
|
||||
// rotation 0, where this is exactly the old expression — so nothing existing moves.
|
||||
w: d.canvas_width ?? footprintOnWall(d.device_id, d.rotation || 0).w,
|
||||
h: d.canvas_height ?? footprintOnWall(d.device_id, d.rotation || 0).h,
|
||||
}));
|
||||
|
||||
// Default player covers the bounding box of all screens; if there are no
|
||||
|
|
@ -224,6 +254,19 @@ async function renderWallEditor(container, wallId) {
|
|||
</select>
|
||||
<button class="btn btn-primary btn-sm" id="setPlaylistBtn" style="margin-left:8px">${t('wall.set_playlist')}</button>
|
||||
</div>
|
||||
|
||||
<!-- #235: a wall used to swallow its members whole — joining one removed the device's card
|
||||
from Displays, so an operator could not see that one panel of a four-panel wall had
|
||||
dropped off, and the only way to check a single screen was to pull it out of the wall
|
||||
(which re-syncs the live wall) and put it back. Panel state and a way through to the
|
||||
device live here now. -->
|
||||
<div style="margin-top:20px">
|
||||
<h3 style="font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:10px">
|
||||
Panels
|
||||
<span id="wallPanelSummary" style="font-size:12px;font-weight:400;color:var(--text-muted)"></span>
|
||||
</h3>
|
||||
<div id="wallPanelStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="width:260px;flex-shrink:0">
|
||||
|
|
@ -234,9 +277,13 @@ async function renderWallEditor(container, wallId) {
|
|||
<div class="info-card" style="margin-top:14px;padding:10px;font-size:12px;line-height:1.55">
|
||||
<strong style="font-size:12px">How it works</strong>
|
||||
<ul style="margin:6px 0 0 14px;padding:0;color:var(--text-secondary)">
|
||||
<li>This canvas is the wall <strong>as the audience sees it</strong>. Arrange the
|
||||
rectangles to match the physical layout.</li>
|
||||
<li>Each rectangle is a physical screen.</li>
|
||||
<li>The blue dashed rectangle is the player window — content plays inside this rect.</li>
|
||||
<li>Each screen shows only the part of the player that overlaps it.</li>
|
||||
<li>Panel hung sideways? Select it and set <em>How this panel is mounted</em> — the
|
||||
tile turns to match and the content is rotated for you.</li>
|
||||
<li>Drag corners to resize, drag the body to move.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -252,6 +299,7 @@ async function renderWallEditor(container, wallId) {
|
|||
for (const s of screens) canvas.appendChild(renderScreenEl(s));
|
||||
updateOverlapsAll();
|
||||
renderSidebar();
|
||||
renderPanelStatus();
|
||||
applySelectionClasses();
|
||||
renderSelectionPanel();
|
||||
applyTransform();
|
||||
|
|
@ -287,12 +335,43 @@ async function renderWallEditor(container, wallId) {
|
|||
<label>W</label><input type="number" data-field="w" value="${Math.round(rect.w)}" step="1" min="40">
|
||||
<label>H</label><input type="number" data-field="h" value="${Math.round(rect.h)}" step="1" min="24">
|
||||
</div>
|
||||
${isPlayer ? '' : `
|
||||
<div style="margin-top:10px">
|
||||
<label style="font-size:11px;color:var(--text-muted);display:block;margin-bottom:3px">How this panel is mounted</label>
|
||||
<select id="screenRotation" class="input" style="width:100%;font-size:12px;background:var(--bg-input)">
|
||||
${WALL_ROTATIONS.map(r => `<option value="${r}" ${(rect.rotation || 0) === r ? 'selected' : ''}>${ROTATION_LABELS[r]}</option>`).join('')}
|
||||
</select>
|
||||
<p style="margin:5px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
|
||||
Lay the canvas out to match the <strong>physical</strong> wall and set this per panel —
|
||||
content is rotated for you, so portrait walls don't need pre-rotated video.
|
||||
While a panel is in a wall this replaces its own Orientation setting.
|
||||
</p>
|
||||
</div>`}
|
||||
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
|
||||
Arrow keys nudge by 1px. Hold <kbd>Shift</kbd> for 10px.
|
||||
Click outside any rect to deselect.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
panel.querySelector('#screenRotation')?.addEventListener('change', (ev) => {
|
||||
const r = getSelectedRect();
|
||||
if (!r) return;
|
||||
const next = parseInt(ev.target.value, 10) || 0;
|
||||
const prev = r.rotation || 0;
|
||||
r.rotation = next;
|
||||
// Turning a panel by a quarter turn changes its footprint on the wall. Swap the tile about
|
||||
// its own CENTRE so it turns in place — resizing from the corner would shove every
|
||||
// neighbouring tile out of alignment and undo the operator's careful placement.
|
||||
const wasQuarter = (prev === 90 || prev === 270);
|
||||
const isQuarter = (next === 90 || next === 270);
|
||||
if (wasQuarter !== isQuarter) {
|
||||
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
||||
const w = r.h, h = r.w;
|
||||
r.w = w; r.h = h; r.x = cx - w / 2; r.y = cy - h / 2;
|
||||
}
|
||||
markDirty();
|
||||
renderAll();
|
||||
});
|
||||
panel.querySelector('#deselectBtn').addEventListener('click', () => {
|
||||
selected = null;
|
||||
applySelectionClasses();
|
||||
|
|
@ -384,6 +463,7 @@ async function renderWallEditor(container, wallId) {
|
|||
<div class="wall-screen-meta">
|
||||
<span class="status-dot ${s.device_status}" style="display:inline-block"></span>
|
||||
<span style="font-size:10px;color:var(--text-muted)">${Math.round(s.w)}×${Math.round(s.h)}</span>
|
||||
${(s.rotation || 0) !== 0 ? `<span class="wall-screen-rot" title="${esc(ROTATION_LABELS[s.rotation])}" style="font-size:10px;color:var(--accent);margin-left:4px">⟳${s.rotation}°</span>` : ''}
|
||||
</div>
|
||||
${renderNoteFor(s.device_id) ? `<div class="wall-screen-rendernote" style="font-size:9px;color:var(--warning,#e0a800);margin-top:2px;line-height:1.2">${esc(renderNoteFor(s.device_id))}</div>` : ''}
|
||||
</div>
|
||||
|
|
@ -453,6 +533,92 @@ async function renderWallEditor(container, wallId) {
|
|||
});
|
||||
}
|
||||
|
||||
// #235: per-panel state for the wall, with a way through to the device itself.
|
||||
// Live socket updates are merged over the fetched rows so a panel that drops off mid-session
|
||||
// turns red here without a reload — "is the wall actually up?" has to be answerable from the
|
||||
// dashboard, because today it means sending someone to look at it.
|
||||
const liveStatus = {};
|
||||
function panelLiveness(deviceId) {
|
||||
const d = deviceById(deviceId) || {};
|
||||
const live = liveStatus[deviceId];
|
||||
return livenessBadge({ ...d, ...(live || {}) }, { short: true });
|
||||
}
|
||||
|
||||
function renderPanelStatus() {
|
||||
const host = document.getElementById('wallPanelStatus');
|
||||
if (!host) return;
|
||||
const summary = document.getElementById('wallPanelSummary');
|
||||
|
||||
if (screens.length === 0) {
|
||||
host.innerHTML = `<p style="color:var(--text-muted);font-size:12px;margin:0">No panels on this wall yet — drag displays onto the canvas.</p>`;
|
||||
if (summary) summary.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const badges = screens.map(s => ({ s, b: panelLiveness(s.device_id) }));
|
||||
const down = badges.filter(x => x.b.state !== 'online').length;
|
||||
if (summary) {
|
||||
summary.textContent = down === 0
|
||||
? `${screens.length} panel${screens.length === 1 ? '' : 's'}, all online`
|
||||
: `${down} of ${screens.length} not online`;
|
||||
summary.style.color = down === 0 ? 'var(--success)' : 'var(--danger, #e5484d)';
|
||||
}
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="wall-panel-list" style="display:flex;flex-direction:column;gap:6px">
|
||||
${badges.map(({ s, b }) => {
|
||||
const d = deviceById(s.device_id) || {};
|
||||
const meta = [
|
||||
d.app_version ? `v${esc(d.app_version)}` : '',
|
||||
(s.rotation || 0) !== 0 ? esc(ROTATION_LABELS[s.rotation]) : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="playlist-item" data-device-id="${esc(s.device_id)}" style="display:flex;align-items:center;gap:10px">
|
||||
<span class="status-dot ${esc(b.state)}" style="display:inline-block;flex-shrink:0"></span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="playlist-item-name" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(s.device_name || d.name || 'Display')}</div>
|
||||
<div class="playlist-item-meta" style="font-size:11px">
|
||||
<span class="wall-panel-liveness"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>${meta ? ` · ${meta}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${(!Array.isArray(d.capabilities) || d.capabilities.includes('remote.screenshot')) ? `
|
||||
<button class="btn btn-sm wall-panel-shot" data-device-id="${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
|
||||
title="Ask this panel for a screenshot — safe on a live wall, it doesn't change what's playing">Screenshot</button>` : ''}
|
||||
<a class="btn btn-sm" href="#/device/${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
|
||||
title="Device info, incident log and remote controls">Open</a>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
|
||||
Opening a panel doesn't remove it from the wall. Pushing different content to one panel
|
||||
will desync the wall — use the wall playlist above instead.
|
||||
</p>`;
|
||||
|
||||
// The button is only rendered for a panel that declares (or baselines to) remote.screenshot —
|
||||
// a BrightSign has no screenshot capability at all, so the old unconditional button popped a
|
||||
// toast promising an image that was never coming.
|
||||
host.querySelectorAll('.wall-panel-shot').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
requestScreenshot(btn.dataset.deviceId);
|
||||
showToast('Screenshot requested — it appears on the panel\'s device page', 'info');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const panelStatusHandler = (data) => {
|
||||
if (!data?.device_id) return;
|
||||
liveStatus[data.device_id] = data;
|
||||
// Keep the canvas tile dots in step with the list, or the two halves of this screen disagree
|
||||
// about whether a panel is up.
|
||||
const scr = screens.find(s => s.device_id === data.device_id);
|
||||
if (scr && data.status) scr.device_status = data.status;
|
||||
const dot = canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(data.device_id)}"] .status-dot`);
|
||||
if (dot && data.status) dot.className = `status-dot ${data.status}`;
|
||||
renderPanelStatus();
|
||||
};
|
||||
on('device-status', panelStatusHandler);
|
||||
cleanupHooks.push(() => off('device-status', panelStatusHandler));
|
||||
|
||||
function renderSidebar() {
|
||||
const sidebar = document.getElementById('availableDevices');
|
||||
const unassigned = getUnassigned();
|
||||
|
|
@ -579,7 +745,11 @@ async function renderWallEditor(container, wallId) {
|
|||
if (data.type !== 'sidebar-device' || !data.device_id) return;
|
||||
const vpRect = viewport.getBoundingClientRect();
|
||||
// #14: size the new tile to the device's render resolution (centered on the drop point).
|
||||
const sz = renderSizeFor(data.device_id);
|
||||
// #236: ...as its FOOTPRINT, so a panel already set to portrait drops in as a tall tile that
|
||||
// matches how it is actually hung, instead of a landscape tile the operator has to reason
|
||||
// backwards from.
|
||||
const rotation = defaultRotationFor(data.device_id);
|
||||
const sz = footprintOnWall(data.device_id, rotation);
|
||||
// Drop pixel → canvas-data coord: undo viewport offset, pan, and zoom.
|
||||
const x = (e.clientX - vpRect.left - pan.x) / zoom - sz.w / 2;
|
||||
const y = (e.clientY - vpRect.top - pan.y) / zoom - sz.h / 2;
|
||||
|
|
@ -587,7 +757,7 @@ async function renderWallEditor(container, wallId) {
|
|||
device_id: data.device_id,
|
||||
device_name: data.device_name || 'Display',
|
||||
device_status: data.device_status || 'offline',
|
||||
grid_col: 0, grid_row: 0, rotation: 0,
|
||||
grid_col: 0, grid_row: 0, rotation,
|
||||
x, y, w: sz.w, h: sz.h,
|
||||
});
|
||||
markDirty();
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ function openContentPicker({ multiple = false, title } = {}) {
|
|||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
|
||||
overlay.innerHTML = `
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column">
|
||||
<h3 style="margin:0 0 12px;color:var(--text-primary)">${title || t('widget.picker.default_title')}</h3>
|
||||
<h3 style="margin:0 0 12px;color:var(--text-primary)">${esc(title || t('widget.picker.default_title'))}</h3>
|
||||
<input type="text" id="cpSearch" class="input" placeholder="${t('widget.picker.search')}" style="margin-bottom:12px">
|
||||
<div id="cpList" style="flex:1;overflow-y:auto;min-height:200px"></div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px;gap:8px;flex-wrap:wrap">
|
||||
|
|
@ -307,6 +307,15 @@ function showPreviewModal(sessionId, widgetType) {
|
|||
<strong style="color:var(--text-primary)">${t('widget.preview_title')}</strong>
|
||||
<button class="btn btn-secondary btn-sm" id="pvClose">${t('widget.close')}</button>
|
||||
</div>
|
||||
<!-- ALWAYS 'allow-scripts', never allow-same-origin, regardless of the org's
|
||||
widget_sandbox_isolation_disabled setting. This preview loads
|
||||
/api/widgets/preview-session/<id> from the DASHBOARD's own origin, and the
|
||||
dashboard keeps its session JWT in localStorage.token. Granting same-origin
|
||||
here would let anyone who can author a widget (workspace_editor and up) run
|
||||
script in the dashboard origin and read the session of whichever admin opens
|
||||
the preview — an editor -> admin escalation. The org setting exists to let
|
||||
PLAYERS embed origin-strict sites; it is not a licence to de-isolate the
|
||||
dashboard. Covered by widget-preview-stays-isolated.test.js. -->
|
||||
<iframe id="pvIframe" sandbox="allow-scripts" style="flex:1;width:100%;border:0;background:#000"></iframe>
|
||||
${webpageNote}
|
||||
</div>`;
|
||||
|
|
@ -400,7 +409,7 @@ export async function render(container) {
|
|||
break;
|
||||
case 'weather':
|
||||
html += `
|
||||
<div class="form-group"><label>${t('widget.field.location')}</label><input type="text" id="wLocation" class="input" value="${config.location || ''}" placeholder="${t('widget.field.location_placeholder')}"></div>
|
||||
<div class="form-group"><label>${t('widget.field.location')}</label><input type="text" id="wLocation" class="input" value="${esc(config.location || '')}" placeholder="${t('widget.field.location_placeholder')}"></div>
|
||||
<div class="form-group"><label>${t('widget.field.units')}</label><select id="wUnits" class="input" style="background:var(--bg-input)"><option value="imperial" ${config.units !== 'metric' ? 'selected' : ''}>${t('widget.field.units_imperial')}</option><option value="metric" ${config.units === 'metric' ? 'selected' : ''}>${t('widget.field.units_metric')}</option></select></div>
|
||||
<div class="form-group"><label>${t('widget.field.font_size')}</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 48}"></div>
|
||||
<div class="form-group"><label>${t('widget.field.color')}</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>`;
|
||||
|
|
@ -429,7 +438,7 @@ export async function render(container) {
|
|||
case 'social':
|
||||
html += `
|
||||
<div class="form-group"><label>${t('widget.field.platform')}</label><select id="wPlatform" class="input" style="background:var(--bg-input)"><option value="twitter">${t('widget.field.platform_twitter')}</option><option value="instagram">${t('widget.field.platform_instagram')}</option></select></div>
|
||||
<div class="form-group"><label>${t('widget.field.query')}</label><input type="text" id="wQuery" class="input" value="${config.query || ''}" placeholder="${t('widget.field.query_placeholder')}"></div>`;
|
||||
<div class="form-group"><label>${t('widget.field.query')}</label><input type="text" id="wQuery" class="input" value="${esc(config.query || '')}" placeholder="${t('widget.field.query_placeholder')}"></div>`;
|
||||
break;
|
||||
case 'directory-board':
|
||||
html += `
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ export function mapMutationError(err) {
|
|||
}
|
||||
|
||||
function renderError(message) {
|
||||
return `<div style="color:var(--danger);font-size:14px;padding:16px;background:var(--bg-input);border-radius:6px">${message}</div>`;
|
||||
return `<div style="color:var(--danger);font-size:14px;padding:16px;background:var(--bg-input);border-radius:6px">${esc(message)}</div>`;
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
|
|
|
|||
|
|
@ -210,6 +210,14 @@
|
|||
</a>
|
||||
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
|
||||
</div>
|
||||
|
||||
<!-- Live deployment count. Hidden until the number arrives, so a self-hosted instance (where
|
||||
the endpoint does not exist) and a brand-new one (where the count is 0) show nothing at
|
||||
all rather than an empty frame or a zero. -->
|
||||
<p id="deployed-stat" hidden style="margin-top:28px;color:var(--muted);font-size:15px">
|
||||
<strong id="deployed-count" style="color:var(--text,inherit);font-variant-numeric:tabular-nums"></strong>
|
||||
screens deployed with ScreenTinker
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Intro video -->
|
||||
|
|
@ -630,6 +638,19 @@
|
|||
// replace it on the public marketing page with a hardcoded Contact Us
|
||||
// card. Other consumers of /api/subscription/plans (billing.js,
|
||||
// settings.js, admin.js) get the full list as before.
|
||||
/* Screens deployed. Only the deployment that collects install statistics answers this;
|
||||
everywhere else it 404s and the line stays hidden. Failures are silent by design —
|
||||
a marketing page must not show a broken stat, and there is nothing a visitor could
|
||||
do about it. */
|
||||
fetch('/api/public/stats')
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(s => {
|
||||
if (!s || !(s.screens > 0)) return;
|
||||
document.getElementById('deployed-count').textContent = s.screens.toLocaleString();
|
||||
document.getElementById('deployed-stat').hidden = false;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
|
||||
const grid = document.getElementById('pricingGrid');
|
||||
const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');
|
||||
|
|
|
|||
7
frontend/vendor/README.md
vendored
7
frontend/vendor/README.md
vendored
|
|
@ -4,9 +4,16 @@ 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
|
||||
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
|
||||
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
|
||||
- **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
|
||||
- **Why committed:** the API reference must render on offline instances — no CDN, no build step.
|
||||
- **Regenerate / update:**
|
||||
|
|
|
|||
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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.
|
||||
112
scripts/android-license-check.js
Normal file
112
scripts/android-license-check.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/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);
|
||||
184
scripts/license-check.js
Normal file
184
scripts/license-check.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/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);
|
||||
|
|
@ -78,6 +78,7 @@ function runMigration({ db: existingDb = null, dryRun = false, logger = console
|
|||
default_brand_name TEXT,
|
||||
default_logo_url TEXT,
|
||||
default_primary_color TEXT,
|
||||
widget_sandbox_isolation_disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,17 +58,51 @@ while [[ $# -gt 0 ]]; do
|
|||
esac
|
||||
done
|
||||
|
||||
# -- Prompting when we are being piped --
|
||||
#
|
||||
# The documented install is `curl -sL … | sudo bash`, which makes stdin the SCRIPT, not the
|
||||
# operator. bash has already consumed it by the time any `read` runs, so every prompt got EOF
|
||||
# instantly: the mode menu "chose" All-in-One without the operator touching anything, and the
|
||||
# Player-Only branch could never be reached that way at all. It looked like the menu was being
|
||||
# skipped, because it was.
|
||||
#
|
||||
# So prompts read from the controlling terminal instead. When there genuinely is no terminal
|
||||
# (cloud-init, a provisioning pipeline), we say so and take the documented default rather than
|
||||
# pretending a choice was made — the operator can pass --player-only / --server-url to decide
|
||||
# without a prompt.
|
||||
if [ -r /dev/tty ] && [ -t 1 ]; then
|
||||
exec 3</dev/tty
|
||||
HAVE_TTY=true
|
||||
else
|
||||
HAVE_TTY=false
|
||||
fi
|
||||
|
||||
# ask <varname> <prompt> [read-args…]
|
||||
ask() {
|
||||
local __var="$1"; shift
|
||||
local __prompt="$1"; shift
|
||||
if [ "$HAVE_TTY" = true ]; then
|
||||
read "$@" -u 3 -r -p "$__prompt" "$__var"
|
||||
else
|
||||
eval "$__var=''"
|
||||
fi
|
||||
}
|
||||
|
||||
# -- Root check --
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
err "This script must be run as root. Try: sudo bash raspberry-pi-setup.sh"
|
||||
err "This script must be run as root. Try: curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash"
|
||||
fi
|
||||
|
||||
# -- Architecture check --
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" != "aarch64" && "$ARCH" != "armv7l" ]]; then
|
||||
warn "Detected architecture: $ARCH (expected aarch64 or armv7l for Raspberry Pi)"
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r; echo
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
|
||||
if [ "$HAVE_TTY" = true ]; then
|
||||
ask REPLY "Continue anyway? (y/N) " -n 1; echo
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
|
||||
else
|
||||
err "Refusing to continue on $ARCH without a terminal to confirm at. Re-run interactively, or on the intended hardware."
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Interactive mode selection (if no flags passed) --
|
||||
|
|
@ -86,14 +120,24 @@ if [ "$PLAYER_ONLY" = false ] && [ -z "$SERVER_URL" ]; then
|
|||
echo " Connects to an existing ScreenTinker server."
|
||||
echo " This Pi just displays content."
|
||||
echo ""
|
||||
read -p "Choose [1/2]: " MODE_CHOICE
|
||||
case "$MODE_CHOICE" in
|
||||
2)
|
||||
PLAYER_ONLY=true
|
||||
read -p "Server URL (e.g., https://screentinker.com): " SERVER_URL
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
if [ "$HAVE_TTY" = false ]; then
|
||||
# No terminal to ask at. Say which way we went, rather than letting an empty answer
|
||||
# look like a decision — this is the exact confusion the piped-stdin bug produced.
|
||||
warn "No terminal available for the menu — defaulting to All-in-One."
|
||||
warn "To choose Player-Only non-interactively: ... | sudo bash -s -- --player-only https://your-server"
|
||||
else
|
||||
ask MODE_CHOICE "Choose [1/2]: "
|
||||
case "$MODE_CHOICE" in
|
||||
2)
|
||||
PLAYER_ONLY=true
|
||||
while [ -z "$SERVER_URL" ]; do
|
||||
ask SERVER_URL "Server URL (e.g., https://screentinker.com): "
|
||||
[ -z "$SERVER_URL" ] && warn "Player-Only needs a server URL."
|
||||
done
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# Strip trailing slash from server URL
|
||||
|
|
@ -268,22 +312,52 @@ KIOSK_URL="${KIOSK_URL}"
|
|||
# Wait for display
|
||||
sleep 2
|
||||
|
||||
# Disable screen blanking and power management
|
||||
xset s off
|
||||
xset s noblank
|
||||
xset -dpms
|
||||
xset s 0 0
|
||||
# Which display server are we actually on? Pi 5 on Bookworm defaults to WAYLAND, where every
|
||||
# X11 tool below is a no-op that prints an error into the journal and silently does nothing —
|
||||
# so a Wayland Pi got no blanking suppression and no cursor hiding while appearing configured.
|
||||
SESSION_TYPE="\${XDG_SESSION_TYPE:-}"
|
||||
if [ -z "\$SESSION_TYPE" ]; then
|
||||
if [ -n "\${WAYLAND_DISPLAY:-}" ]; then SESSION_TYPE=wayland
|
||||
elif [ -n "\${DISPLAY:-}" ]; then SESSION_TYPE=x11
|
||||
fi
|
||||
fi
|
||||
echo "Display server: \${SESSION_TYPE:-unknown}"
|
||||
|
||||
# Hide cursor after 3 seconds of inactivity
|
||||
unclutter -idle 3 -root &
|
||||
if [ "\$SESSION_TYPE" = "wayland" ]; then
|
||||
# Blanking/DPMS belong to the compositor here, not to us. wlopm is present on Pi OS
|
||||
# (wlroots-based wayfire/labwc); if it is not, the compositor's own idle config is the
|
||||
# documented fallback and README says so.
|
||||
command -v wlopm >/dev/null 2>&1 && wlopm --on '*' 2>/dev/null || true
|
||||
# unclutter is X11-only — it exits immediately here, which is why a Wayland Pi kept its cursor
|
||||
# on screen while the install looked complete. Hiding it is the COMPOSITOR's job on Wayland;
|
||||
# the installer configures wayfire's hide-cursor plugin at install time (section 9b). If this
|
||||
# Pi runs labwc instead, there is no equivalent setting and the cursor stays — README says so
|
||||
# rather than this pretending otherwise.
|
||||
else
|
||||
# Disable screen blanking and power management
|
||||
xset s off
|
||||
xset s noblank
|
||||
xset -dpms
|
||||
xset s 0 0
|
||||
|
||||
# Clean Chromium crash flags (prevents restore session dialogs)
|
||||
# Hide cursor after 3 seconds of inactivity (X11 only — no Wayland equivalent)
|
||||
unclutter -idle 3 -root &
|
||||
fi
|
||||
|
||||
# Clean Chromium crash flags (prevents restore session dialogs).
|
||||
#
|
||||
# The white page on every boot after the first is Chromium restoring a session it thinks
|
||||
# crashed: a kiosk is killed by the shutdown, never exits cleanly, and comes back with a
|
||||
# restore surface on top of the player — which is why ALT+F4 "fixed" it (it closed the
|
||||
# surface, not the player). Rewriting the flags is not enough on its own because Chromium
|
||||
# also replays the previous window set from Sessions/, so those go too.
|
||||
CDIR="\$HOME/.config/chromium/Default"
|
||||
mkdir -p "\$CDIR"
|
||||
if [ -f "\$CDIR/Preferences" ]; then
|
||||
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' "\$CDIR/Preferences" 2>/dev/null || true
|
||||
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' "\$CDIR/Preferences" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "\$CDIR/Sessions" "\$CDIR/Session Storage" 2>/dev/null || true
|
||||
|
||||
# Wait for local server if running all-in-one
|
||||
if echo "\$KIOSK_URL" | grep -q "localhost"; then
|
||||
|
|
@ -306,8 +380,15 @@ if [ -z "\$SCREEN_W" ] || [ -z "\$SCREEN_H" ]; then
|
|||
SCREEN_H=1080
|
||||
fi
|
||||
|
||||
# Wayland needs the ozone backend named explicitly on some Bookworm builds; on X11 the flag
|
||||
# is absent so nothing changes there.
|
||||
OZONE=""
|
||||
[ "\$SESSION_TYPE" = "wayland" ] && OZONE="--ozone-platform=wayland"
|
||||
|
||||
exec ${CHROMIUM_BIN} \\
|
||||
--kiosk \\
|
||||
\$OZONE \\
|
||||
--password-store=basic \\
|
||||
--window-position=0,0 \\
|
||||
--window-size=\${SCREEN_W},\${SCREEN_H} \\
|
||||
--noerrdialogs \\
|
||||
|
|
@ -454,6 +535,49 @@ ExecStart=-/sbin/agetty --autologin ${PI_USER} --noclear %I \$TERM
|
|||
EOF
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 9b. Wayland cursor hiding (wayfire)
|
||||
# ============================================================
|
||||
# On X11 the launcher runs `unclutter -idle 3`. On Wayland unclutter cannot work at all — there is
|
||||
# no root window to track and no client may move or hide another client's cursor — so hiding it is
|
||||
# the compositor's decision. Pi OS Bookworm on Pi 4/5 defaults to wayfire, which has a hide-cursor
|
||||
# plugin; this configures it. A previous version of this script claimed in a comment to do exactly
|
||||
# this and never did, so a Wayland Pi sat there with a mouse pointer on the sign (#245).
|
||||
#
|
||||
# Written at install time rather than from the launcher because wayfire reads this at session
|
||||
# start. It is also idempotent and non-destructive: a Pi whose owner has already tuned wayfire.ini
|
||||
# keeps their settings, and the file is backed up before the first edit either way.
|
||||
if [ -f "$PI_HOME/.config/wayfire.ini" ]; then
|
||||
log "Configuring wayfire to hide the cursor..."
|
||||
WF="$PI_HOME/.config/wayfire.ini"
|
||||
[ -f "${WF}.screentinker-bak" ] || cp "$WF" "${WF}.screentinker-bak"
|
||||
|
||||
if grep -q '^\[hide-cursor\]' "$WF"; then
|
||||
log " wayfire.ini already has [hide-cursor] — leaving it alone"
|
||||
else
|
||||
printf '\n[hide-cursor]\nhide_delay = 3000\n' >> "$WF"
|
||||
fi
|
||||
|
||||
# The plugin only loads if it is named in core's plugin list, and that list is space-separated
|
||||
# on one line. Appending to it is fiddly enough to be worth doing carefully rather than with a
|
||||
# blind sed: only touch the line when it exists and does not already mention us.
|
||||
if grep -qE '^\s*plugins\s*=' "$WF"; then
|
||||
if ! grep -E '^\s*plugins\s*=' "$WF" | grep -q 'hide-cursor'; then
|
||||
sed -i 's/^\(\s*plugins\s*=.*\)$/\1 hide-cursor/' "$WF"
|
||||
fi
|
||||
else
|
||||
warn "wayfire.ini has no [core] plugins line — add 'hide-cursor' to it to hide the pointer"
|
||||
fi
|
||||
chown "$PI_USER":"$PI_USER" "$WF" 2>/dev/null || true
|
||||
elif [ "$HAS_DESKTOP" = true ]; then
|
||||
# labwc (the newer Pi OS compositor) has no cursor-hiding option, and neither do we from the
|
||||
# outside. Say so plainly instead of leaving the operator to wonder whether it failed.
|
||||
if command -v labwc >/dev/null 2>&1; then
|
||||
warn "This Pi appears to run labwc, which has no cursor-hide setting — the pointer will stay visible."
|
||||
warn "Switch to wayfire (raspi-config > Advanced > Wayland) or to X11 if a hidden cursor matters."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 10. Pi display and boot optimizations
|
||||
# ============================================================
|
||||
|
|
@ -573,6 +697,52 @@ case "${1:-server}" in
|
|||
esac
|
||||
LOGSEOF
|
||||
chmod +x /usr/local/bin/screentinker-logs
|
||||
else
|
||||
# Player-Only gets its own pair. It used to get NONE, while section 12 below wrote an MOTD
|
||||
# advertising all three to every install — so a player Pi greeted its operator at each SSH
|
||||
# login with three commands that were never on it (#245). There is no server here to update,
|
||||
# so screentinker-update is genuinely not applicable and is not offered; status and logs are,
|
||||
# and a player with no way to answer "is it running?" is the harder machine to support.
|
||||
log "Creating management scripts (player)..."
|
||||
|
||||
cat > /usr/local/bin/screentinker-status << PSTATUSEOF
|
||||
#!/bin/bash
|
||||
echo ""
|
||||
echo "=== ScreenTinker Player Status ==="
|
||||
echo ""
|
||||
if systemctl is-active screentinker-kiosk.service &>/dev/null; then
|
||||
echo "Kiosk: RUNNING"
|
||||
else
|
||||
echo "Kiosk: STOPPED (screentinker-logs to see why)"
|
||||
fi
|
||||
echo "Server: ${SERVER_URL}"
|
||||
# Whether this player can actually reach the server it was pointed at — the first question worth
|
||||
# asking on a panel that is showing nothing.
|
||||
if curl -sf --max-time 5 "${SERVER_URL}/api/status" >/dev/null 2>&1; then
|
||||
echo "Reachable: yes"
|
||||
else
|
||||
echo "Reachable: NO (network, DNS, or the server is down)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Uptime: \$(uptime -p)"
|
||||
echo "CPU Temp: \$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2 || echo 'n/a')"
|
||||
echo "Disk: \$(df -h / 2>/dev/null | tail -1 | awk '{print \$3 "/" \$2 " (" \$5 " used)"}')"
|
||||
echo "Memory: \$(free -h | awk '/Mem:/ {print \$3 " / " \$2}')"
|
||||
echo ""
|
||||
PSTATUSEOF
|
||||
chmod +x /usr/local/bin/screentinker-status
|
||||
|
||||
cat > /usr/local/bin/screentinker-logs << 'PLOGSEOF'
|
||||
#!/bin/bash
|
||||
# Only the kiosk exists on a player, so it is the default AND the only target. Accepting
|
||||
# "server" here and following an empty unit would be a worse answer than saying so.
|
||||
case "${1:-kiosk}" in
|
||||
kiosk|all) journalctl -u screentinker-kiosk.service -f --no-hostname ;;
|
||||
server) echo "This is a player-only install — there is no local server. Point at your server's logs instead." ;;
|
||||
*) echo "Usage: screentinker-logs [kiosk]" ;;
|
||||
esac
|
||||
PLOGSEOF
|
||||
chmod +x /usr/local/bin/screentinker-logs
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
|
|
@ -580,20 +750,37 @@ fi
|
|||
# ============================================================
|
||||
cat > /etc/motd << 'MOTDEOF'
|
||||
|
||||
____ _____ _
|
||||
/ ___| ___ _ __ ___ ___ |_ _|_ _ __ | | _____ _ __
|
||||
\___ \ / __| '__/ _ \/ _ \ | || | '_ \| |/ / _ \ '__|
|
||||
___) | (__| | | __/ __/ | || | | | | < __/ |
|
||||
|____/ \___|_| \___|\___| |_||_|_| |_|_|\_\___|_|
|
||||
____ _____ _ _
|
||||
/ ___| ___ _ __ ___ ___ _ __ |_ _|(_) _ __ | | __ ___ _ __
|
||||
\___ \ / __|| '__| / _ \ / _ \| '_ \ | | | || '_ \ | |/ / / _ \| '__|
|
||||
___) || (__ | | | __/| __/| | | | | | | || | | || < | __/| |
|
||||
|____/ \___||_| \___| \___||_| |_| |_| |_||_| |_||_|\_\ \___||_|
|
||||
|
||||
Open-Source Digital Signage for Any Screen
|
||||
|
||||
MOTDEOF
|
||||
|
||||
# The command list is appended SEPARATELY and per-mode, because section 11 creates
|
||||
# screentinker-update on an All-in-One install only. A single hard-coded list here is what made a
|
||||
# Player-Only Pi advertise three commands it did not have, at every SSH login (#245). The MOTD is
|
||||
# the first thing an operator reads on a machine that is misbehaving, which makes it the worst
|
||||
# place in the system to be confidently wrong.
|
||||
if [ "$PLAYER_ONLY" = false ]; then
|
||||
cat >> /etc/motd << 'MOTDCMDEOF'
|
||||
Commands:
|
||||
screentinker-status Show system info and URLs
|
||||
screentinker-update Pull latest and restart
|
||||
screentinker-logs Follow logs (server|kiosk|all)
|
||||
|
||||
MOTDEOF
|
||||
MOTDCMDEOF
|
||||
else
|
||||
cat >> /etc/motd << 'MOTDCMDEOF'
|
||||
Commands:
|
||||
screentinker-status Kiosk state, server URL, and whether it is reachable
|
||||
screentinker-logs Follow the kiosk log
|
||||
|
||||
MOTDCMDEOF
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 13. Clean up legacy remotedisplay naming
|
||||
|
|
|
|||
|
|
@ -84,11 +84,14 @@ module.exports = {
|
|||
return secret;
|
||||
})(),
|
||||
jwtExpiry: '7d',
|
||||
// Google OAuth - set these in env or here
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||
// Microsoft OAuth - set these in env or here
|
||||
microsoftClientId: process.env.MICROSOFT_CLIENT_ID || '',
|
||||
microsoftTenantId: process.env.MICROSOFT_TENANT_ID || 'common',
|
||||
/*
|
||||
* Google and Microsoft sign-in are configured through lib/oidc-providers.js, which reads
|
||||
* process.env directly — there is nothing here for it to read, so these fields were dead, and
|
||||
* `microsoftTenantId` defaulting to 'common' actively contradicted the provider code, which now
|
||||
* REFUSES 'common' (it advertises a template issuer that can never match, and accepting it means
|
||||
* accepting tokens from every Azure tenant — nOAuth). Removed rather than left as a trap for the
|
||||
* next person who greps for where Microsoft SSO is configured.
|
||||
*/
|
||||
// Stripe (optional - for paid subscriptions)
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
|
|
@ -226,6 +229,11 @@ module.exports = {
|
|||
// is LOWER than the old hardcoded 7 days (the reporter's bloat happened under 7d);
|
||||
// 2-3 days is plenty for the dashboard's 24h uptime view + diagnostics.
|
||||
statusLogRetentionDays: parseFloat(process.env.STATUS_LOG_RETENTION_DAYS) || 3,
|
||||
// #240 device_telemetry age retention (pruneTelemetryRetention in db/database.js). The
|
||||
// per-heartbeat row cap only trims devices that are still reporting; this closes the
|
||||
// rows left behind by ones that stopped. 30d matches the uptime report's default window,
|
||||
// so it can only remove rows the report would not have shown anyway.
|
||||
telemetryRetentionDays: parseFloat(process.env.TELEMETRY_RETENTION_DAYS) || 30,
|
||||
// #146 HARD per-device row-count ceiling on device_status_log, enforced by the
|
||||
// global sweep alongside the age delete above. Age-based retention can't bound a
|
||||
// write storm (rows are all younger than the window), so a reconnect storm grew
|
||||
|
|
@ -303,6 +311,24 @@ module.exports = {
|
|||
// ...or escalate if the WAL grew across this many consecutive PASSIVE runs (PASSIVE not
|
||||
// keeping up even below the high-water). Belt-and-suspenders with the MB bound above.
|
||||
walCheckpointStarvationRuns: parseInt(process.env.WAL_CHECKPOINT_STARVATION_RUNS) || 3,
|
||||
// #240: ...but growth alone is NOT starvation. Any sustained write burst — a fleet
|
||||
// powering on in the morning — grows the WAL across several consecutive PASSIVE runs
|
||||
// while it is still tiny. Escalating there buys nothing (there is nothing to reclaim)
|
||||
// and costs a lot: TRUNCATE is the BLOCKING form, and it blocks across connections, so
|
||||
// every main-thread statement issued during it sits in SQLite's busy handler (5s by
|
||||
// default in better-sqlite3) — a multi-second loop stall at exactly the moment the
|
||||
// fleet is reconnecting. So the growth signal may only escalate once the WAL is big
|
||||
// enough for a blocking checkpoint to be worth it. The high-water mark above is
|
||||
// unchanged and remains the hard backstop, so the WAL still cannot grow unbounded.
|
||||
// Set at half the high-water mark: a WAL still in the lower half is not worth blocking
|
||||
// for, and anything in the upper half is close enough to the backstop to be worth it.
|
||||
walCheckpointStarvationFloorMB: parseFloat(process.env.WAL_CHECKPOINT_STARVATION_FLOOR_MB) || 8,
|
||||
// #240: the floor alone is not enough — a WAL that already sits above it (Bold's was
|
||||
// 6.2MB against a 16MB high-water) would still escalate on every burst. So the growth
|
||||
// path is ALSO rate-limited: however long the write pressure lasts, our own maintenance
|
||||
// may stall the loop at most once per this window. The high-water escalation is
|
||||
// deliberately EXEMPT — that one is the runaway-WAL backstop and must never be delayed.
|
||||
walCheckpointEscalateCooldownMs: parseInt(process.env.WAL_CHECKPOINT_ESCALATE_COOLDOWN_MS) || 300000,
|
||||
// Worker-death handling: with autocheckpoint=0 a dead worker means nothing checkpoints and
|
||||
// the WAL grows until the disk fills. An unexpectedly-dead worker is respawned up to
|
||||
// RespawnMax times per RespawnWindowMs (with a small backoff); if that's exhausted we
|
||||
|
|
|
|||
|
|
@ -382,6 +382,121 @@ const migrations = [
|
|||
// the PUBLIC address the server sees the connection arrive from — both are useful and they are
|
||||
// not the same thing. A customer reading the public IP as "my screen's IP" prompted this.
|
||||
"ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT",
|
||||
// ...and its IPv6 one, in its own column rather than sharing the above. The player's collector
|
||||
// filtered to Inet4Address, so a v6-only panel reported no address at all and the dashboard
|
||||
// showed a dash for a screen that had a perfectly reachable address. Separate columns because a
|
||||
// dual-stack panel genuinely has both and an operator may need either — collapsing them would
|
||||
// make the field mean "whichever we happened to enumerate first".
|
||||
"ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT",
|
||||
// What is physically PLUGGED IN, read from the display's EDID, and the mode actually being
|
||||
// driven. A signage operator's first question about a dark screen is which panel it is and
|
||||
// whether the player is outputting at all — the dashboard could say neither, and
|
||||
// screen_width/height are what the PAGE thinks it has, not what the hardware negotiated.
|
||||
//
|
||||
// Per-telemetry-row rather than on `devices` because a display can be swapped, unplugged or
|
||||
// renegotiated without the player re-registering, and because a dual-output player registers ONE
|
||||
// ROW PER OUTPUT (see output_index) — each row must carry its own screen, not the box's first.
|
||||
/*
|
||||
* Per-organization SSO.
|
||||
*
|
||||
* Instance-wide providers come from the environment and belong to whoever runs the server. These
|
||||
* belong to a CUSTOMER: an organization brings its own identity provider, and its people sign in
|
||||
* with it without the operator touching a config file.
|
||||
*
|
||||
* `slug` is globally unique and randomly generated rather than chosen, because it is a URL path
|
||||
* segment (/api/auth/oidc/<slug>/start) and two organizations both wanting "okta" must not be
|
||||
* able to collide — or to guess each other's. The admin only ever sees `name`.
|
||||
*
|
||||
* `client_secret_enc` is AES-256-GCM via lib/secretbox, the same at-rest treatment as TOTP
|
||||
* secrets and BYOK AI keys. PKCE means a secret is optional, so a public client stores NULL.
|
||||
*
|
||||
* `email_domains` is the list an admin TYPED, kept for display and for the edit form. It does not
|
||||
* drive routing — org_sso_domains does, and only its verified rows (see the table below). The two
|
||||
* are not interchangeable: reading this column to decide who may sign in would let a tenant route
|
||||
* a domain it never proved.
|
||||
*/
|
||||
`CREATE TABLE IF NOT EXISTS org_sso_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
issuer TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret_enc TEXT,
|
||||
scopes TEXT NOT NULL DEFAULT 'openid email profile',
|
||||
email_domains TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_org_sso_org ON org_sso_providers(organization_id)",
|
||||
/*
|
||||
* Claimed sign-in domains, and the proof that the claimant controls them.
|
||||
*
|
||||
* `org_sso_providers.email_domains` used to be the whole story, and first-claim-wins on a text
|
||||
* field is not a claim — it is a land grab. A tenant could type a domain it had nothing to do
|
||||
* with and every person at that company typing their work address into the login page would be
|
||||
* routed to the squatter's identity provider. It also let one account permanently deny a domain
|
||||
* to its real owner, and strand accounts at addresses it never owned.
|
||||
*
|
||||
* So a domain is inert until DNS says otherwise. `verified_at` NULL means claimed but unproven:
|
||||
* it routes nobody, and the login callback will not accept an assertion for it. The row still
|
||||
* reserves the name, so two tenants cannot race the same domain, but reserving is all it does.
|
||||
*
|
||||
* `token` is what has to appear in DNS. It is per-domain rather than per-organization so that
|
||||
* publishing one proof cannot be replayed to claim a second domain.
|
||||
*/
|
||||
`CREATE TABLE IF NOT EXISTS org_sso_domains (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
provider_id TEXT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
token TEXT NOT NULL,
|
||||
-- When the current token was issued. An UNVERIFIED claim is only good for 8 hours from here:
|
||||
-- past that the token is dead and the reservation lapses, so a domain nobody can prove cannot
|
||||
-- be held indefinitely by whoever typed it first. Verified rows ignore this entirely.
|
||||
token_issued_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
verified_at INTEGER,
|
||||
last_checked_at INTEGER,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
-- A verified row never expires and domain is globally UNIQUE, so a row that outlives its
|
||||
-- provider blocks that domain for EVERYONE, forever, while being invisible in the API. The
|
||||
-- delete handler clears these explicitly; this is the backstop for every other route out
|
||||
-- (an organization cascade, a manual delete, a future caller that forgets).
|
||||
FOREIGN KEY (provider_id) REFERENCES org_sso_providers(id) ON DELETE CASCADE
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_org ON org_sso_domains(organization_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_provider ON org_sso_domains(provider_id)",
|
||||
/*
|
||||
* SSO-ONLY: an organization may require its people to use its identity provider, so a password
|
||||
* is no longer an alternative way in. That is the point of buying SSO — the IdP holds the MFA,
|
||||
* the conditional access and the instant deprovisioning, and a password box beside it is a way
|
||||
* around all three.
|
||||
*
|
||||
* ⚠️ Asymmetric on purpose. Turning it ON is the safe direction and an org admin does it alone.
|
||||
* Turning it OFF is how a compromised admin would re-open password login, and it is also what
|
||||
* an org will demand at its worst moment — IdP down, nobody can work — which is exactly when a
|
||||
* self-service switch gets flipped under pressure. So removal goes through the operator: the
|
||||
* request is recorded here and a platform admin has to approve it.
|
||||
*/
|
||||
`CREATE TABLE IF NOT EXISTS org_sso_only_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
requested_by TEXT,
|
||||
reason TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | cancelled
|
||||
decided_by TEXT,
|
||||
decided_at INTEGER,
|
||||
decision_note TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_only_req_status ON org_sso_only_requests(status, organization_id)",
|
||||
"ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT",
|
||||
"ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT",
|
||||
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because
|
||||
// only some hardware exposes one — Android and the browser players send nothing and must keep
|
||||
// reading as "no sensor" rather than "0 degrees", which is why every read site treats null as
|
||||
|
|
@ -472,6 +587,7 @@ const migrations = [
|
|||
// additive — existing rows are unaffected and a code-only rollback leaves dead columns.
|
||||
"ALTER TABLE users ADD COLUMN password_reset_hash TEXT",
|
||||
"ALTER TABLE users ADD COLUMN password_reset_expires INTEGER",
|
||||
"ALTER TABLE organizations ADD COLUMN widget_sandbox_isolation_disabled INTEGER NOT NULL DEFAULT 0",
|
||||
// AUTH-05: make break-glass recovery revocable, single-use and auditable.
|
||||
//
|
||||
// scripts/reset-admin.js mints a JWT carrying `recovery: true`, which middleware/auth.js
|
||||
|
|
@ -525,6 +641,19 @@ const migrations = [
|
|||
// it can do nothing and must be respected.
|
||||
'ALTER TABLE devices ADD COLUMN capabilities TEXT',
|
||||
|
||||
// Opt-in install statistics, COLLECTOR side only — inert unless TELEMETRY_COLLECTOR=1, which
|
||||
// is the hosted deployment. Keyed by instance_id and upserted rather than appended, so it is a
|
||||
// table of current state ("this install last reported N screens") rather than an event log that
|
||||
// grows without bound on a box nobody prunes. Answering "how many screens are deployed" needs
|
||||
// the latest row per install, never the history.
|
||||
`CREATE TABLE IF NOT EXISTS telemetry_reports (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT,
|
||||
screen_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
)`,
|
||||
|
||||
];
|
||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||
// error means the column is already present (expected on a migrated DB) - benign.
|
||||
|
|
@ -549,6 +678,40 @@ for (const sql of migrations) {
|
|||
}
|
||||
if (_migApplied > 0) console.log(`[migrate] applied ${_migApplied} new column migration(s)`);
|
||||
|
||||
/*
|
||||
* Say something when per-org SSO domains predate the proof requirement.
|
||||
*
|
||||
* Domains used to be a comma list an admin typed, and that list routed logins. They now route only
|
||||
* once DNS proves them, so on an instance upgraded from an earlier build of this feature every one
|
||||
* of those domains silently stops working — the provider still says "enabled", the typed list is
|
||||
* still on screen, and every federated user in that organization is locked out with no self-service
|
||||
* way back.
|
||||
*
|
||||
* They are deliberately NOT auto-claimed. A claim now notifies the operator, reserves the name
|
||||
* against other tenants and starts an 8-hour clock; manufacturing all of that on an admin's behalf,
|
||||
* for domains nobody ever proved, is not a migration's decision to make. So: name them, loudly,
|
||||
* once per boot, and let an admin re-add the ones they still want.
|
||||
*/
|
||||
try {
|
||||
const stranded = db.prepare(`
|
||||
SELECT p.slug, p.name, p.organization_id, p.email_domains
|
||||
FROM org_sso_providers p
|
||||
WHERE p.email_domains != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM org_sso_domains d WHERE d.provider_id = p.id)
|
||||
`).all();
|
||||
if (stranded.length) {
|
||||
console.warn(`[migrate] ⚠️ ${stranded.length} SSO provider(s) have typed domains that were never verified.`);
|
||||
console.warn('[migrate] Domains now route only after a DNS TXT record proves them, so these route NOBODY:');
|
||||
for (const r of stranded) {
|
||||
console.warn(`[migrate] ${r.name} (${r.slug}, org ${r.organization_id}): ${r.email_domains}`);
|
||||
}
|
||||
console.warn('[migrate] Re-add each domain in Settings to get its record, then Verify. See README, "Proving a domain".');
|
||||
}
|
||||
} catch (e) {
|
||||
// The table may not exist yet on a first boot; that is not a problem worth a stack trace.
|
||||
if (!/no such table/i.test(e.message)) console.error('[migrate] SSO domain check failed:', e.message);
|
||||
}
|
||||
|
||||
// #74/#75 per-item schedules: the playlist_item_schedules table is created
|
||||
// idempotently by schema.sql (CREATE TABLE IF NOT EXISTS, run every boot, so it
|
||||
// self-applies on upgrade). Record it in schema_migrations for observability.
|
||||
|
|
@ -809,6 +972,27 @@ migrateGroupSchedules();
|
|||
// updates workspace_id.
|
||||
ensureMultitenancyMigration();
|
||||
|
||||
/*
|
||||
* `organizations.sso_only` — added HERE, not in the migrations array above.
|
||||
*
|
||||
* That array runs BEFORE ensureMultitenancyMigration(), which is what creates the organizations
|
||||
* table, so on a fresh install the ALTER hit a table that did not exist yet: `[migrate] FAILED …
|
||||
* no such table: organizations`, one console.error among ~85 migration lines. The instance then
|
||||
* ran its entire first boot with the SSO settings screen 500ing and — far worse —
|
||||
* ssoOnlyForEmail() catching `no such column` and returning "not SSO-only", which is password
|
||||
* login proceeding for an organization that had switched it off. It self-healed on the second
|
||||
* boot, which is exactly what makes it easy to miss.
|
||||
*/
|
||||
try {
|
||||
const orgCols = db.prepare('PRAGMA table_info(organizations)').all().map((c) => c.name);
|
||||
if (orgCols.length && !orgCols.includes('sso_only')) {
|
||||
db.exec('ALTER TABLE organizations ADD COLUMN sso_only INTEGER NOT NULL DEFAULT 0');
|
||||
console.log('[migrate] added organizations.sso_only');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[migrate] could not add organizations.sso_only:', e.message);
|
||||
}
|
||||
|
||||
// Phase 2.2c migration: backfill content_folders.workspace_id from owner's
|
||||
// default workspace. The ALTER lives in the migrations array above; this
|
||||
// one-shot populates the column for any rows that pre-date it.
|
||||
|
|
@ -1116,6 +1300,40 @@ function pruneTelemetry(deviceId) {
|
|||
_delTelemetry.run(deviceId, config.statusLogPruneBatch);
|
||||
}
|
||||
|
||||
// #240: the per-heartbeat cap above is the only thing that ever trimmed device_telemetry,
|
||||
// and it only trims the device whose heartbeat is being handled — so a device that STOPS
|
||||
// reporting (decommissioned, swapped, seasonally dark) leaves its rows behind forever and
|
||||
// the table only ever grows. This is the matching age sweep, mirroring pruneStatusLog:
|
||||
// per-device so it rides idx_telemetry_device(device_id, reported_at DESC) instead of
|
||||
// scanning, chunked so a backlog trims across many bounded DELETEs, and yielding between
|
||||
// devices so it can never own the loop.
|
||||
//
|
||||
// The retention default is deliberately LOOSER than the per-device cap (6000 rows ~= 25h
|
||||
// for a device reporting every 15s) and matches the uptime report's default 30-day window,
|
||||
// so this sweep cannot change a report that the row cap wasn't already truncating.
|
||||
const _nextTelemetryDevice = db.prepare('SELECT device_id FROM device_telemetry WHERE device_id > ? ORDER BY device_id LIMIT 1');
|
||||
const _delTelemetryOld = db.prepare('DELETE FROM device_telemetry WHERE rowid IN (SELECT rowid FROM device_telemetry WHERE device_id = ? AND reported_at < ? LIMIT ?)');
|
||||
let _telemetryPruneRunning = false;
|
||||
async function pruneTelemetryRetention(opts = {}) {
|
||||
if (_telemetryPruneRunning) return 0;
|
||||
if (opts.bandGate && config.maintenanceBandGateEnabled && currentBand() !== 'normal') return 0;
|
||||
_telemetryPruneRunning = true;
|
||||
try {
|
||||
const batch = config.statusLogPruneBatch;
|
||||
const cutoff = Math.floor(Date.now() / 1000) - Math.round(config.telemetryRetentionDays * 86400);
|
||||
let total = 0, lastDev = '';
|
||||
for (;;) {
|
||||
const row = _nextTelemetryDevice.get(lastDev); // O(log n) seek to the next distinct device_id
|
||||
if (!row) break;
|
||||
lastDev = row.device_id;
|
||||
total += (await chunkedDelete((lim) => _delTelemetryOld.run(lastDev, cutoff, lim).changes, { batch })).deleted;
|
||||
await yieldTick(); // breathe between devices
|
||||
}
|
||||
if (total > 0) console.log(`[telemetry] pruned ${total} row(s) older than ${config.telemetryRetentionDays}d (per-device, batches of ${batch})`);
|
||||
return total;
|
||||
} catch (_) { return 0; } finally { _telemetryPruneRunning = false; }
|
||||
}
|
||||
|
||||
// Prune old screenshots (keep only latest per device)
|
||||
function pruneScreenshots(deviceId) {
|
||||
const old = db.prepare(`
|
||||
|
|
@ -1176,4 +1394,4 @@ try {
|
|||
const { verifyAndRepairSchema } = require('../lib/schema-check');
|
||||
verifyAndRepairSchema(db);
|
||||
|
||||
module.exports = { db, pruneTelemetry, pruneScreenshots, pruneStatusLog, getMaintenanceStats };
|
||||
module.exports = { db, pruneTelemetry, pruneTelemetryRetention, pruneScreenshots, pruneStatusLog, getMaintenanceStats };
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const { workerData, parentPort } = require('worker_threads');
|
|||
const fs = require('fs');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const { dbPath, intervalMs, highWaterBytes, starvationRuns } = workerData;
|
||||
const { dbPath, intervalMs, highWaterBytes, starvationRuns, starvationFloorBytes, escalateCooldownMs } = workerData;
|
||||
|
||||
// Fault injection for TESTS ONLY (env-gated; inert in prod). Exits immediately on start so
|
||||
// the controller's respawn / autocheckpoint-fallback path can be exercised deterministically.
|
||||
|
|
@ -25,7 +25,9 @@ const walFile = dbPath + '-wal';
|
|||
function walBytes() { try { return fs.statSync(walFile).size; } catch { return 0; } }
|
||||
|
||||
let lastBytes = 0;
|
||||
let growthRuns = 0; // consecutive PASSIVE runs where the WAL failed to shrink
|
||||
let growthRuns = 0; // consecutive PASSIVE runs where the WAL failed to shrink
|
||||
let lastTruncateAt = 0; // #240: when we last blocked for a TRUNCATE (0 = never)
|
||||
let coolingReported = false;
|
||||
let timer = null;
|
||||
|
||||
function tick() {
|
||||
|
|
@ -36,16 +38,47 @@ function tick() {
|
|||
const bytes = walBytes();
|
||||
|
||||
// --- STARVATION BOUND (this is where "WAL cannot grow forever" is enforced) ---
|
||||
// Either signal forces a TRUNCATE, which BLOCKS until it has checkpointed everything
|
||||
// and truncated the file to 0. Blocking is fatal on the loop but FINE here on the worker.
|
||||
// Escalating forces a TRUNCATE, which BLOCKS until it has checkpointed everything and
|
||||
// truncated the file to 0. #240: "fine here on the worker" was only ever half true —
|
||||
// the fsync is off the loop, but SQLite's locks are held across CONNECTIONS, so the
|
||||
// main thread's next statement waits it out in the busy handler. Hence the gates below.
|
||||
if (bytes > lastBytes) growthRuns++; else growthRuns = 0;
|
||||
const overHighWater = bytes > highWaterBytes;
|
||||
const starved = growthRuns >= starvationRuns;
|
||||
// #240: TRUNCATE blocks ACROSS connections — the main thread's next statement waits in
|
||||
// SQLite's busy handler for the whole checkpoint — so the growth signal alone must not
|
||||
// be able to spend it. Two gates, because either on its own leaves the hole open:
|
||||
// FLOOR: a WAL in the lower half of its budget has little to reclaim; blocking for it
|
||||
// is pure cost. (Ungated, every morning fleet power-on wave bought a loop stall.)
|
||||
// COOLDOWN: a WAL that already sits ABOVE the floor would otherwise escalate on every
|
||||
// burst forever. However long the pressure lasts, we stall the loop at most once
|
||||
// per window and let PASSIVE do the rest.
|
||||
// overHighWater bypasses both — a runaway WAL is the one case worth blocking for.
|
||||
const sinceLast = Date.now() - lastTruncateAt;
|
||||
const starved = growthRuns >= starvationRuns && bytes >= starvationFloorBytes;
|
||||
const cooling = starved && lastTruncateAt > 0 && sinceLast < escalateCooldownMs;
|
||||
|
||||
if (cooling && !overHighWater) {
|
||||
// Report the transition only — a starved-and-cooling state persists for the whole
|
||||
// window and this check runs every interval; one line, not a log flood.
|
||||
if (!coolingReported) {
|
||||
coolingReported = true;
|
||||
post(`starvation escalation held off (WAL ${(bytes / 1e6).toFixed(1)}MB, last TRUNCATE ${Math.round(sinceLast / 1000)}s ago) — PASSIVE continues`);
|
||||
}
|
||||
lastBytes = bytes;
|
||||
return;
|
||||
}
|
||||
|
||||
if (overHighWater || starved) {
|
||||
db.pragma('wal_checkpoint(TRUNCATE)', { simple: false });
|
||||
lastTruncateAt = Date.now();
|
||||
coolingReported = false;
|
||||
const r = db.pragma('wal_checkpoint(TRUNCATE)', { simple: false });
|
||||
const after = walBytes();
|
||||
post(`escalated TRUNCATE (${overHighWater ? 'high-water' : 'starvation'}): WAL ${(bytes / 1e6).toFixed(1)}MB -> ${(after / 1e6).toFixed(1)}MB`);
|
||||
// #240: TRUNCATE does NOT throw when it can't get the locks — it returns busy=1 having
|
||||
// sat on SQLite's busy timeout for its full duration. Measured at ~4.9s with a single
|
||||
// reader mid-transaction, reclaiming nothing, while every main-thread statement waited
|
||||
// behind it. Say so plainly: a silent 5-second loss is the worst thing this can do.
|
||||
const busy = Array.isArray(r) && r[0] && r[0].busy === 1;
|
||||
post(`escalated TRUNCATE (${overHighWater ? 'high-water' : 'starvation'}): WAL ${(bytes / 1e6).toFixed(1)}MB -> ${(after / 1e6).toFixed(1)}MB${busy ? ' — BUSY: reclaimed nothing, blocked writers for the busy timeout' : ''}`);
|
||||
growthRuns = 0;
|
||||
lastBytes = after;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
// autocheckpoint on the main connection as a degraded-but-safe fallback (occasional inline
|
||||
// stall << unbounded WAL growth).
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { Worker } = require('worker_threads');
|
||||
const config = require('../config');
|
||||
|
||||
|
|
@ -29,6 +30,8 @@ function spawnWorker() {
|
|||
intervalMs: config.walCheckpointIntervalMs,
|
||||
highWaterBytes: config.walCheckpointHighWaterMB * 1024 * 1024,
|
||||
starvationRuns: config.walCheckpointStarvationRuns,
|
||||
starvationFloorBytes: config.walCheckpointStarvationFloorMB * 1024 * 1024, // #240
|
||||
escalateCooldownMs: config.walCheckpointEscalateCooldownMs, // #240
|
||||
},
|
||||
});
|
||||
w.on('message', (m) => { if (m && m.log) console.log('[wal-checkpoint] ' + m.log); });
|
||||
|
|
@ -77,8 +80,27 @@ function engageFallback() {
|
|||
if (fallbackEngaged) return;
|
||||
fallbackEngaged = true;
|
||||
try { mainDb.pragma(`wal_autocheckpoint = ${config.walCheckpointFallbackPages}`); } catch (_) {}
|
||||
try { mainDb.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) {} // one-time reclaim of the dead-worker backlog
|
||||
console.error('[wal-checkpoint] worker unrecoverable — re-enabled inline autocheckpoint as fallback');
|
||||
// #240: reclaim the dead worker's backlog, but pick the CHEAPEST form that does the job.
|
||||
// The old unconditional TRUNCATE ran a blocking, fsync-heavy checkpoint on the MAIN
|
||||
// thread — on slow storage a single multi-second loop stall, and one that only ever
|
||||
// happens on a degraded server that can least afford it. PASSIVE reclaims what it can
|
||||
// without blocking; the blocking form is reserved for a WAL that is genuinely over the
|
||||
// high-water mark, where leaving it is the worse of the two risks.
|
||||
const over = walBytes() > config.walCheckpointHighWaterMB * 1024 * 1024;
|
||||
try { mainDb.pragma(`wal_checkpoint(${over ? 'TRUNCATE' : 'PASSIVE'})`); } catch (_) {}
|
||||
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
|
||||
// back on the main thread until a restart. That is exactly the shape of "it degrades with
|
||||
// uptime and a restart fixes it", so it must be visible on /api/status rather than inferable
|
||||
// only from a log line that may have rolled.
|
||||
function getCheckpointerState() {
|
||||
return { worker: !!worker, fallbackEngaged, respawns: respawnAt.length, walBytes: walBytes() };
|
||||
}
|
||||
|
||||
function walBytes() {
|
||||
try { return mainDbPath ? fs.statSync(mainDbPath + '-wal').size : 0; } catch (_) { return 0; }
|
||||
}
|
||||
|
||||
// Call ONCE at boot, after the DB is open + migrated. `db` is the main connection (used to
|
||||
|
|
@ -99,7 +121,17 @@ function startWalCheckpointer(db, dbPath) {
|
|||
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) { /* best-effort */ }
|
||||
|
||||
worker = spawnWorker();
|
||||
console.log(`[wal-checkpoint] off-thread checkpointer started (every ${config.walCheckpointIntervalMs}ms; escalate >${config.walCheckpointHighWaterMB}MB or ${config.walCheckpointStarvationRuns} growing runs; respawn max ${config.walCheckpointRespawnMax}/${config.walCheckpointRespawnWindowMs}ms)`);
|
||||
// #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,
|
||||
// which is the half that no longer holds on its own — and reading it during an incident would
|
||||
// send you looking for a checkpoint that the gates had in fact suppressed.
|
||||
console.log(
|
||||
`[wal-checkpoint] off-thread checkpointer started (PASSIVE every ${config.walCheckpointIntervalMs}ms; ` +
|
||||
`blocking TRUNCATE when the WAL exceeds ${config.walCheckpointHighWaterMB}MB, ` +
|
||||
`or after ${config.walCheckpointStarvationRuns} growing runs but only at >=${config.walCheckpointStarvationFloorMB}MB ` +
|
||||
`and at most once per ${Math.round(config.walCheckpointEscalateCooldownMs / 1000)}s; ` +
|
||||
`respawn max ${config.walCheckpointRespawnMax}/${config.walCheckpointRespawnWindowMs}ms)`
|
||||
);
|
||||
return worker;
|
||||
}
|
||||
|
||||
|
|
@ -115,4 +147,4 @@ async function stopWalCheckpointer() {
|
|||
try { await w.terminate(); } catch (_) {}
|
||||
}
|
||||
|
||||
module.exports = { startWalCheckpointer, stopWalCheckpointer, _getWorker: () => worker };
|
||||
module.exports = { startWalCheckpointer, stopWalCheckpointer, getCheckpointerState, _getWorker: () => worker };
|
||||
|
|
|
|||
78
server/lib/brightsign-snapshot-queue.js
Normal file
78
server/lib/brightsign-snapshot-queue.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Pending framebuffer-capture requests for BrightSign players, held for the host to collect.
|
||||
*
|
||||
* WHY THIS EXISTS, because it looks like a detour and is not:
|
||||
*
|
||||
* Every other player is TOLD to take a screenshot — the server emits `device:screenshot-request`
|
||||
* over the device socket and the page captures itself. A BrightSign cannot capture itself: video
|
||||
* decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the
|
||||
* content missing. Only the host (BrightScript) can get a real capture, via the player's own DWS.
|
||||
*
|
||||
* The obvious route to the host is the page: st-bridge.js posts a message over the widget's
|
||||
* messageport. On real hardware (XT245, BOS 9.1.93.2) that channel is dead after page load —
|
||||
* instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all
|
||||
* while the page was posting, though the boot-time probe round-trips. The registry is not an
|
||||
* alternative either: a running BrightScript does not observe registry writes made by anyone else,
|
||||
* including ones made externally through the DWS.
|
||||
*
|
||||
* What the host CAN do is HTTP — it already fetches its own package updates that way. So the
|
||||
* direction is inverted: the request waits here, and the host collects it on the loop it is
|
||||
* already running. The image comes back over a plain POST, so a capture works even when the page
|
||||
* is wedged, which is exactly when an operator most wants to see the screen.
|
||||
*
|
||||
* Deliberately in memory. A capture request is worthless a minute after it was made — an operator
|
||||
* clicked a button and is watching for the result — so persisting it would only add a way to
|
||||
* deliver a stale screenshot after a restart.
|
||||
*/
|
||||
|
||||
// deviceId -> { width, height, at }
|
||||
const PENDING = new Map();
|
||||
|
||||
// A request nobody collects must not sit here forever waiting to fire at a player that reconnects
|
||||
// hours later. Comfortably longer than the dashboard's own 15s patience, short enough that the
|
||||
// answer still refers to what the operator was looking at.
|
||||
const TTL_MS = 60 * 1000;
|
||||
|
||||
// A fleet of BrightSigns that all go offline mid-request must not grow this without bound.
|
||||
const MAX_PENDING = 500;
|
||||
|
||||
function request(deviceId, opts) {
|
||||
if (!deviceId) return false;
|
||||
const o = opts || {};
|
||||
if (!PENDING.has(deviceId) && PENDING.size >= MAX_PENDING) {
|
||||
// Drop the OLDEST rather than refuse the newest: the newest is the one someone is watching for.
|
||||
const oldest = PENDING.keys().next().value;
|
||||
if (oldest !== undefined) PENDING.delete(oldest);
|
||||
}
|
||||
// Re-requesting replaces rather than queues. A dashboard polling the button, or a 1fps remote
|
||||
// stream, must not build a backlog the host then works through long after anyone stopped looking.
|
||||
PENDING.set(deviceId, {
|
||||
width: Number(o.width) > 0 ? Math.min(3840, Math.round(o.width)) : 960,
|
||||
height: Number(o.height) > 0 ? Math.min(2160, Math.round(o.height)) : 540,
|
||||
at: Date.now(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Collect and clear. Returns null when there is nothing pending or it has expired. */
|
||||
function take(deviceId) {
|
||||
const p = PENDING.get(deviceId);
|
||||
if (!p) return null;
|
||||
PENDING.delete(deviceId);
|
||||
if (Date.now() - p.at > TTL_MS) return null;
|
||||
return { width: p.width, height: p.height };
|
||||
}
|
||||
|
||||
/* Drop anything expired. Called from the same sweep as the other bounded stores. */
|
||||
function sweep(now) {
|
||||
const t = now || Date.now();
|
||||
let dropped = 0;
|
||||
for (const [id, p] of PENDING) {
|
||||
if (t - p.at > TTL_MS) { PENDING.delete(id); dropped++; }
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
module.exports = { request, take, sweep, TTL_MS, MAX_PENDING, _size: () => PENDING.size };
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const { preCmp } = require('./version-precedence');
|
||||
|
||||
/*
|
||||
* Should a BrightSign player replace its own host package (autorun.zip)?
|
||||
*
|
||||
|
|
@ -54,7 +56,9 @@ function compareVersions(a, b) {
|
|||
if (A.pre === B.pre) return 0;
|
||||
if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1
|
||||
if (B.pre === null) return -1;
|
||||
return A.pre < B.pre ? -1 : 1; // rc1 < rc2, lexicographic is right for our naming
|
||||
// Natural compare, NOT lexicographic: rc10 must outrank rc9. This file carried the same
|
||||
// "lexicographic is right for our naming" assumption that broke the Android OTA path.
|
||||
return preCmp(A.pre, B.pre);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -22,38 +22,66 @@ function safeFilename(name) {
|
|||
return sanitizeString((name || '').normalize('NFC'));
|
||||
}
|
||||
|
||||
// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content
|
||||
// row. Returns the content row. Throws on a hard failure (the caller maps to 500);
|
||||
// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before.
|
||||
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
|
||||
const id = uuidv4();
|
||||
// Content-derived extension + mime. Throws UnsupportedUploadError (and removes the temp
|
||||
// file) when the bytes are not a supported media type; the caller maps that to a 400.
|
||||
const { filepath, mime } = finalizeUpload(file);
|
||||
/*
|
||||
* Everything we can learn from the BYTES: thumbnail, display dimensions, duration.
|
||||
*
|
||||
* Extracted so PUT /api/content/:id/replace derives them the same way an upload does. It used
|
||||
* to carry its own shorter copy that handled images only — so replacing a video wiped the row's
|
||||
* duration, dimensions and thumbnail, and replacing a portrait photo re-introduced the EXIF
|
||||
* orientation bug (#170) that the ingest path fixes with imageDisplayDims + .rotate(). A second
|
||||
* copy of this logic is a second place for it to rot; there is now one.
|
||||
*
|
||||
* Best-effort by contract: a missing ffprobe or a decode failure yields nulls and a warning, never
|
||||
* a throw — the file itself is already stored and is worth more than its metadata.
|
||||
*
|
||||
* @returns {{width:number|null, height:number|null, durationSec:number|null, thumbnailPath:string|null}}
|
||||
*/
|
||||
async function deriveMediaMetadata(sourcePath, filepath, mime) {
|
||||
let width = null, height = null, durationSec = null, thumbnailPath = null;
|
||||
|
||||
try {
|
||||
// SVG is deliberately NOT handed to sharp: rasterising it goes through librsvg, which
|
||||
// is where the outstanding libvips CVEs live, and an SVG is already its own thumbnail.
|
||||
// SVG is deliberately NOT rasterised: it is already its own thumbnail. (It also used to be
|
||||
// the one format kept away from sharp, because rasterising went through librsvg — where the
|
||||
// outstanding libvips CVEs live. Nothing rasterises it now either.)
|
||||
if (mime === 'image/svg+xml') {
|
||||
thumbnailPath = filepath;
|
||||
} else if (mime.startsWith('image/')) {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(file.path).metadata();
|
||||
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape.
|
||||
const imageOps = require('./image-ops');
|
||||
const thumbName = `thumb_${filepath}`;
|
||||
// Measure and thumbnail from ONE decode. Asking separately costs two, and a decode is the
|
||||
// single most expensive thing on this path (~1s for a 12MP photo — unlike sharp, whose
|
||||
// .metadata() only read the header). #170: rotation is implicit, the decoder auto-orients,
|
||||
// so the recorded dimensions and the thumbnail agree without an explicit rotate.
|
||||
const metadata = await imageOps.measureAndThumbnail(
|
||||
sourcePath, path.join(config.contentDir, thumbName), config.thumbnailWidth, 70);
|
||||
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape. The decoder
|
||||
// applies it and reports orientation 1, so this is a no-op pass-through today — kept so the
|
||||
// rule lives in one place regardless of which decoder is underneath.
|
||||
({ width, height } = imageDisplayDims(metadata));
|
||||
thumbnailPath = `thumb_${filepath}`;
|
||||
await sharp(file.path)
|
||||
.rotate() // #170: auto-orient per EXIF (and strip the tag) so the thumbnail matches
|
||||
.resize(config.thumbnailWidth)
|
||||
.jpeg({ quality: 70 })
|
||||
.toFile(path.join(config.contentDir, thumbnailPath));
|
||||
// Assign thumbnailPath only if the write actually succeeded: naming it unconditionally used
|
||||
// to store a phantom thumbnail_path for a file that was never created, which the UI then
|
||||
// requests forever as a broken image. The dimensions above survive that failure on purpose —
|
||||
// they are independently useful, and losing them would letterbox the asset wrongly.
|
||||
if (metadata.thumbnailWritten) thumbnailPath = thumbName;
|
||||
else console.warn(`Thumbnail write failed for ${filepath}: ${metadata.thumbnailError}`);
|
||||
} else if (mime.startsWith('video/')) {
|
||||
try {
|
||||
const { execFileSync } = require('child_process');
|
||||
const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', file.path],
|
||||
// execFile, NOT execFileSync. These two spawns each carry a 15s timeout, and run
|
||||
// synchronously they block the event loop for their whole duration — nothing else on
|
||||
// the server runs, including heartbeats and socket traffic. That was survivable while
|
||||
// the only caller was a human-initiated upload; it stopped being survivable the moment
|
||||
// a boot-time sweep started walking a whole library of them unattended, which is the
|
||||
// #240 failure mode exactly (blocked loop -> missed heartbeats -> panels marked
|
||||
// offline -> reconnect churn) arriving from our own maintenance.
|
||||
//
|
||||
// deriveMediaMetadata is already async and both callers already await it, so awaiting
|
||||
// the subprocess instead of blocking on it is invisible to them.
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { stdout: probe } = await execFileAsync('ffprobe',
|
||||
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', sourcePath],
|
||||
{ timeout: 15000 }
|
||||
).toString();
|
||||
);
|
||||
const info = JSON.parse(probe);
|
||||
if (info.format?.duration) durationSec = parseFloat(info.format.duration);
|
||||
const videoStream = info.streams?.find(s => s.codec_type === 'video');
|
||||
|
|
@ -62,11 +90,15 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
|
|||
// (ffmpeg auto-rotates the thumbnail below by default, so only the dims need fixing.)
|
||||
({ width, height } = videoDisplayDims(videoStream));
|
||||
}
|
||||
thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
|
||||
// Same phantom-path discipline as the image branch above: name it only once the
|
||||
// file exists, so a failed encode cannot leave the row claiming a thumbnail.
|
||||
const thumbName = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
|
||||
try {
|
||||
execFileSync('ffmpeg', ['-y', '-i', file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)],
|
||||
await execFileAsync('ffmpeg',
|
||||
['-y', '-i', sourcePath, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbName)],
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
thumbnailPath = thumbName;
|
||||
} catch { thumbnailPath = null; }
|
||||
} catch (e) {
|
||||
console.warn('ffprobe failed:', e.message);
|
||||
|
|
@ -75,6 +107,18 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
|
|||
} catch (e) {
|
||||
console.warn('Thumbnail/metadata generation failed:', e.message);
|
||||
}
|
||||
return { width, height, durationSec, thumbnailPath };
|
||||
}
|
||||
|
||||
// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content
|
||||
// row. Returns the content row. Throws on a hard failure (the caller maps to 500);
|
||||
// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before.
|
||||
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
|
||||
const id = uuidv4();
|
||||
// Content-derived extension + mime. Throws UnsupportedUploadError (and removes the temp
|
||||
// file) when the bytes are not a supported media type; the caller maps that to a 400.
|
||||
const { filepath, mime } = finalizeUpload(file);
|
||||
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(file.path, filepath, mime);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height, folder_id)
|
||||
|
|
@ -84,4 +128,4 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
|
|||
return db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
module.exports = { ingestUploadedFile, safeFilename };
|
||||
module.exports = { ingestUploadedFile, safeFilename, deriveMediaMetadata };
|
||||
|
|
|
|||
154
server/lib/domain-verify.js
Normal file
154
server/lib/domain-verify.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Proving that a tenant controls a sign-in domain.
|
||||
*
|
||||
* Per-organization SSO routes everyone at a domain to that organization's identity provider. That
|
||||
* is exactly right when the organization owns the domain and an account-takeover primitive when it
|
||||
* does not — and typing a domain into a form is not ownership. A review demonstrated the whole
|
||||
* chain: claim a company's domain, sign in as a named address there, and the real owner is left
|
||||
* unable to reach an account bearing their own address.
|
||||
*
|
||||
* DNS is the check, because control of a domain's DNS is what "owning a domain" means in the only
|
||||
* sense that matters here. It is also the mechanism every other vendor uses, so the instructions
|
||||
* are already familiar to the person who has to follow them.
|
||||
*
|
||||
* ONE RECORD FORM — a TXT record at a dedicated name:
|
||||
*
|
||||
* _screentinker-verify.example.com. IN TXT "st-verify=<token>"
|
||||
*
|
||||
* A CNAME alternative was drafted and dropped. It would have pointed at
|
||||
* `<token>.verify.screentinker.com`, which requires operating a wildcard DNS zone that answers for
|
||||
* every token ever issued — infrastructure this project does not have, so the instructions would
|
||||
* have described a check that could never pass. TXT needs nothing but the customer's own zone.
|
||||
*
|
||||
* A dedicated `_`-prefixed name is used rather than the apex on purpose: an apex TXT record sits
|
||||
* alongside SPF and DMARC, where a careless edit breaks mail, and it is the one record set an
|
||||
* administrator is most reluctant to touch.
|
||||
*
|
||||
* ⚠️ THE PROOF NAME MUST NOT BE A CNAME. A TXT lookup follows CNAMEs transparently, and RFC 4592
|
||||
* means a wildcard `*.example.com` synthesizes `_screentinker-verify.example.com` too — so a
|
||||
* wildcard CNAME pointing anywhere the attacker controls would let them prove a domain they do not
|
||||
* own. That turns an ordinary subdomain takeover into an apex takeover, and from there into every
|
||||
* `@example.com` login. ACME's dns-01 permits this delegation deliberately; here the thing being
|
||||
* delegated is the whole company's sign-in, so it is refused instead.
|
||||
*/
|
||||
|
||||
const dns = require('dns').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
const RECORD_PREFIX = '_screentinker-verify';
|
||||
const TXT_PREFIX = 'st-verify=';
|
||||
|
||||
// A DNS answer that never arrives must not hold an HTTP request open. The resolver's own retries
|
||||
// sit under this, so it is a ceiling on the whole lookup rather than on one query.
|
||||
const LOOKUP_TIMEOUT_MS = 5000;
|
||||
|
||||
/*
|
||||
* How long an UNVERIFIED claim is worth anything.
|
||||
*
|
||||
* A claim reserves the domain so two tenants cannot race it — but a reservation that never lapses
|
||||
* is squatting with extra steps: type a company's domain, prove nothing, and hold it against its
|
||||
* real owner forever. Eight hours is comfortably longer than a DNS change takes to publish and
|
||||
* propagate, and short enough that an unprovable claim is gone by the next working day.
|
||||
*
|
||||
* The token dies with the claim. Trying again mints a NEW token, so an old record left in DNS from
|
||||
* a lapsed attempt proves nothing, and a domain that changed hands cannot be verified with the
|
||||
* previous holder's value.
|
||||
*
|
||||
* A VERIFIED domain is not affected — proof already happened, and re-proving on a timer would log
|
||||
* out a customer over a DNS edit made months later.
|
||||
*/
|
||||
const CLAIM_TTL_S = 8 * 60 * 60;
|
||||
|
||||
/** True when an unverified claim has run out of time and no longer reserves anything. */
|
||||
function isClaimExpired(row, nowS = Math.floor(Date.now() / 1000)) {
|
||||
if (!row) return false;
|
||||
// `verified_at` is compared to null, NOT tested for truthiness: SQL asks `IS NOT NULL` and a
|
||||
// stored 0 would otherwise be "verified" to the router and "unverified" here — two definitions of
|
||||
// the same word, which is how a domain ends up routing while the code believes it cannot.
|
||||
if (row.verified_at !== null && row.verified_at !== undefined) return false;
|
||||
return (Number(row.token_issued_at) || 0) + CLAIM_TTL_S <= nowS;
|
||||
}
|
||||
|
||||
/** Tokens are compared, so they are random and long enough that guessing is not a strategy. */
|
||||
const newToken = () => crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const recordName = (domain) => `${RECORD_PREFIX}.${domain}`;
|
||||
|
||||
/** Exactly what the admin has to publish — shown in the UI, so it is built in one place. */
|
||||
function instructions(domain, token) {
|
||||
return {
|
||||
record_name: recordName(domain),
|
||||
txt_value: `${TXT_PREFIX}${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('DNS lookup timed out')), ms);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/*
|
||||
* Look for the proof.
|
||||
*
|
||||
* Both record types are queried together and either one is enough. NXDOMAIN and "no such record"
|
||||
* are ordinary answers here — the overwhelmingly common case is an admin checking before the record
|
||||
* has propagated — so they are reported as "not found yet", never as an error to be alarmed by.
|
||||
*
|
||||
* ⚠️ Resolution uses the system resolver, which is the same view of DNS the operator already
|
||||
* trusts. A tenant that can poison that resolver can forge a proof, but a tenant that can do that
|
||||
* has already won something larger.
|
||||
*/
|
||||
async function check(domain, token) {
|
||||
const name = recordName(domain);
|
||||
const wantTxt = `${TXT_PREFIX}${token}`;
|
||||
|
||||
/*
|
||||
* Refuse before looking at the TXT at all if the name is delegated. Checking afterwards would
|
||||
* still be safe, but doing it first means the answer never depends on what the delegation target
|
||||
* happens to say.
|
||||
*/
|
||||
try {
|
||||
const cnames = await withTimeout(dns.resolveCname(name), LOOKUP_TIMEOUT_MS);
|
||||
if (cnames && cnames.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} is a CNAME (to ${cnames[0]}). The record must be a TXT record in this `
|
||||
+ 'domain\u2019s own zone — a delegated name would let whoever controls the target prove this domain.',
|
||||
};
|
||||
}
|
||||
} catch { /* no CNAME is the normal and wanted case */ }
|
||||
|
||||
let records;
|
||||
try {
|
||||
records = await withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS);
|
||||
} catch (e) {
|
||||
// NXDOMAIN and "no such record" are the ORDINARY answers here — an admin checking before the
|
||||
// record has propagated — so they are "not found yet", not an error to be alarmed by.
|
||||
if (/timed out/i.test(e.message)) return { ok: false, error: 'the DNS lookup timed out — try again shortly' };
|
||||
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
|
||||
}
|
||||
|
||||
// resolveTxt returns arrays of string chunks — a value over 255 bytes is split, so join first.
|
||||
for (const chunks of records) {
|
||||
if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' };
|
||||
}
|
||||
|
||||
// Present but wrong is a different problem from absent, and the fixes differ: one needs
|
||||
// correcting, the other needs publishing. A wildcard TXT lands here, which is right — it answers
|
||||
// with its own value, and that is not a proof of anything. (A wildcard CNAME is refused above.)
|
||||
if (records.length) {
|
||||
const found = records.map((c) => c.join('')).join('; ');
|
||||
return { ok: false, error: `${name} exists but does not match. Found: ${found}` };
|
||||
}
|
||||
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
check, instructions, newToken, recordName, isClaimExpired,
|
||||
CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX,
|
||||
};
|
||||
142
server/lib/image-ops-core.js
Normal file
142
server/lib/image-ops-core.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Pure-JavaScript image operations — the two things the ingest path ever asked sharp for:
|
||||
* measure an image, and write a thumbnail.
|
||||
*
|
||||
* THIS FILE IS THE WORK, NOT THE ENTRY POINT. Callers use ./image-ops, which runs these on a
|
||||
* worker thread; everything here is CPU-bound pure JS that would otherwise stall the event loop
|
||||
* for ~1s per 12MP photo. Requiring this module directly is only correct inside the worker (or in
|
||||
* image-ops' inline fallback). See ./image-ops for why.
|
||||
*
|
||||
* WHY NOT SHARP: sharp is a native module wrapping libvips. That costs us a prebuilt binary per
|
||||
* platform/ABI, and when there isn't one (or Node moves ABI) the failure is
|
||||
* ERR_DLOPEN_FAILED/NODE_MODULE_VERSION at require time — the same class of breakage
|
||||
* lib/preflight-deps.js exists to explain for better-sqlite3. Nothing in here is native, so the
|
||||
* server runs anywhere Node runs, including the embedded targets that have no toolchain.
|
||||
*
|
||||
* FORMAT COVERAGE vs the sharp it replaces:
|
||||
* jpeg png gif tiff bmp Jimp, natively
|
||||
* webp avif @jsquash/* — WebAssembly, bundled, no network (see wasmDecode below)
|
||||
* svg never reaches here; callers thumbnail an SVG with itself
|
||||
* heic unsupported — and it already was. sharp lists `heif`, but its
|
||||
* prebuilt libvips has AV1 only and refuses HEVC ("Unsupported
|
||||
* compression"), so .heic uploads have never produced a thumbnail.
|
||||
*
|
||||
* ORIENTATION (#170): Jimp applies EXIF orientation when it decodes and rewrites the tag to 1,
|
||||
* so what comes back is already DISPLAY dimensions — the rotation sharp needed an explicit
|
||||
* .rotate() for. metadata() therefore reports orientation 1 and lets imageDisplayDims() run as a
|
||||
* no-op rather than swapping W/H a second time. Report the tag honestly and that helper stays
|
||||
* correct for any future decoder that does NOT auto-orient.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { sniffMime } = require('./upload-sniff');
|
||||
|
||||
// Jimp is ESM-first but ships a CJS entry; require() is fine and keeps this file loadable from
|
||||
// the CommonJS server. Deferred so a caller that never touches an image never pays for it.
|
||||
let _jimp = null;
|
||||
function jimp() {
|
||||
if (!_jimp) _jimp = require('jimp');
|
||||
return _jimp;
|
||||
}
|
||||
|
||||
/*
|
||||
* @jsquash's decoders are browser-first: they locate their .wasm with
|
||||
* `fetch(new URL('...wasm', import.meta.url))`. Under Node that URL is a file:// one and Node's
|
||||
* fetch does not implement file://, so the bundled binary never loads and the only symptom is a
|
||||
* bare "fetch failed". The binary IS on disk in the package — read and compile it ourselves, then
|
||||
* hand the Module to init(). No network, at install time or after.
|
||||
*/
|
||||
const WASM_CODECS = {
|
||||
'image/webp': { pkg: '@jsquash/webp', wasm: '@jsquash/webp/codec/dec/webp_dec.wasm' },
|
||||
'image/avif': { pkg: '@jsquash/avif', wasm: '@jsquash/avif/codec/dec/avif_dec.wasm' },
|
||||
};
|
||||
const decoderCache = new Map();
|
||||
|
||||
async function wasmDecode(mime, buf) {
|
||||
const spec = WASM_CODECS[mime];
|
||||
if (!spec) return null;
|
||||
if (!decoderCache.has(mime)) {
|
||||
decoderCache.set(mime, (async () => {
|
||||
const mod = await import(`${spec.pkg}/decode.js`);
|
||||
await mod.init(await WebAssembly.compile(fs.readFileSync(require.resolve(spec.wasm))));
|
||||
return mod.default;
|
||||
})());
|
||||
}
|
||||
const decode = await decoderCache.get(mime);
|
||||
return decode(buf); // -> ImageData-ish { data, width, height }
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode to a Jimp image whatever the format. Reuses sniffMime rather than carrying a second copy
|
||||
* of the magic-byte table — routes/media.js already duplicating it once is noted there as a smell.
|
||||
* Throws on anything undecodable, which is the contract callers already handle (a failure yields
|
||||
* null metadata and no thumbnail, never a lost upload).
|
||||
*/
|
||||
async function readImage(src) {
|
||||
const buf = await fs.promises.readFile(src);
|
||||
const mime = sniffMime(buf);
|
||||
|
||||
if (WASM_CODECS[mime]) {
|
||||
const raw = await wasmDecode(mime, buf);
|
||||
if (!raw) throw new Error(`no decoder for ${mime}`);
|
||||
return jimp().Jimp.fromBitmap({ data: Buffer.from(raw.data), width: raw.width, height: raw.height });
|
||||
}
|
||||
return jimp().Jimp.read(buf);
|
||||
}
|
||||
|
||||
/*
|
||||
* Display dimensions, shaped like the sharp metadata the callers already destructure.
|
||||
* orientation is 1 because the decode above already applied it — see ORIENTATION note at the top.
|
||||
*/
|
||||
async function metadata(src) {
|
||||
const img = await readImage(src);
|
||||
return { width: img.bitmap.width, height: img.bitmap.height, orientation: 1 };
|
||||
}
|
||||
|
||||
/*
|
||||
* Resize-and-encode an ALREADY DECODED image. Never upscales: sharp's resize() would enlarge a
|
||||
* small source, but a thumbnail bigger than its original is pure waste and callers only shrink.
|
||||
* Mutates img, so measure before calling.
|
||||
*/
|
||||
async function encodeThumbnail(img, destPath, width, quality) {
|
||||
if (img.bitmap.width > width) img.resize({ w: width });
|
||||
await fs.promises.writeFile(destPath, await img.getBuffer('image/jpeg', { quality }));
|
||||
}
|
||||
|
||||
/*
|
||||
* Write a JPEG thumbnail `width` px wide, aspect preserved — sharp's
|
||||
* .rotate().resize(width).jpeg({quality}).toFile(). Rotation is implicit in the decode.
|
||||
*/
|
||||
async function writeThumbnail(src, destPath, width, quality = 70) {
|
||||
await encodeThumbnail(await readImage(src), destPath, width, quality);
|
||||
}
|
||||
|
||||
/*
|
||||
* Measure AND thumbnail from a SINGLE decode — what ingest actually wants.
|
||||
*
|
||||
* Calling metadata() then writeThumbnail() decodes the file twice. That was free under sharp,
|
||||
* whose .metadata() only parses the header, but here every decode is the full ~1s of a 12MP
|
||||
* photo, so the naive pairing doubled the most expensive thing the ingest path does.
|
||||
*
|
||||
* A thumbnail failure must NOT discard the dimensions: they are independently useful (the player
|
||||
* needs them to letterbox correctly) and that is how the two-call version behaved, since width and
|
||||
* height were already assigned before the thumbnail was written. So the write is reported, not
|
||||
* thrown — and the caller assigns a thumbnail_path only when thumbnailWritten is true, keeping the
|
||||
* phantom-path discipline that stops the UI requesting a file that was never created.
|
||||
* A DECODE failure still throws: there is nothing to report about an unreadable image.
|
||||
*/
|
||||
async function measureAndThumbnail(src, destPath, width, quality = 70) {
|
||||
const img = await readImage(src);
|
||||
const measured = { width: img.bitmap.width, height: img.bitmap.height, orientation: 1 };
|
||||
try {
|
||||
await encodeThumbnail(img, destPath, width, quality);
|
||||
return { ...measured, thumbnailWritten: true, thumbnailError: null };
|
||||
} catch (err) {
|
||||
return { ...measured, thumbnailWritten: false, thumbnailError: err && err.message ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { metadata, writeThumbnail, measureAndThumbnail, readImage };
|
||||
30
server/lib/image-ops-worker.js
Normal file
30
server/lib/image-ops-worker.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Worker-thread host for image-ops-core. One job per message, one reply per job, keyed by id.
|
||||
*
|
||||
* Deliberately thin: every decision (queueing, lifecycle, fallback) lives in ../lib/image-ops so
|
||||
* there is one place to reason about them. This end only does the work and reports what happened.
|
||||
*
|
||||
* Errors come back as a message rather than a thrown exception, so one undecodable upload does
|
||||
* not tear down the worker and take the queued jobs of unrelated callers with it.
|
||||
*/
|
||||
|
||||
const { parentPort } = require('worker_threads');
|
||||
const core = require('./image-ops-core');
|
||||
|
||||
const OPS = {
|
||||
metadata: (job) => core.metadata(job.src),
|
||||
writeThumbnail: (job) => core.writeThumbnail(job.src, job.dest, job.width, job.quality),
|
||||
measureAndThumbnail: (job) => core.measureAndThumbnail(job.src, job.dest, job.width, job.quality),
|
||||
};
|
||||
|
||||
parentPort.on('message', async (job) => {
|
||||
try {
|
||||
const op = OPS[job.op];
|
||||
if (!op) throw new Error(`unknown image op: ${job.op}`);
|
||||
parentPort.postMessage({ id: job.id, ok: true, result: await op(job) });
|
||||
} catch (err) {
|
||||
parentPort.postMessage({ id: job.id, ok: false, error: err && err.message ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
147
server/lib/image-ops.js
Normal file
147
server/lib/image-ops.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Image operations, off the main thread.
|
||||
*
|
||||
* WHY THIS EXISTS: image-ops-core is pure JavaScript, so unlike the native sharp it replaced —
|
||||
* which handed work to a libvips threadpool — its CPU cost lands on whatever thread calls it. A
|
||||
* 12MP photo measures at ~1.0s of solid, uninterruptible main-thread work. That is not a slow
|
||||
* upload, it is a stalled event loop: no heartbeats, no socket traffic, nothing. lib/thumbnail-
|
||||
* backfill.js walks an entire content library at boot, so in-process it reproduces #240 exactly
|
||||
* (blocked loop -> missed heartbeats -> panels marked offline -> reconnect churn), arriving from
|
||||
* our own maintenance. The same reasoning already moved this file's video branch from
|
||||
* execFileSync to execFile; this is that fix for the image branch.
|
||||
*
|
||||
* The work is therefore hosted on a worker thread and this module is the only entry point.
|
||||
*
|
||||
* ONE JOB AT A TIME, deliberately. Decoding holds a full RGBA bitmap — a 12MP photo is ~48MB — so
|
||||
* letting jobs overlap multiplies peak memory by the queue depth, which is exactly the wrong
|
||||
* failure on the small targets this whole change is meant to reach. Serialized, the ceiling is one
|
||||
* image regardless of how many uploads land at once. It also costs nothing in throughput: the work
|
||||
* is CPU-bound, and a single busy worker already saturates the core it runs on.
|
||||
*
|
||||
* The worker is unref'd while idle so it never holds the process open — scripts/backfill-rotation-
|
||||
* dims.js is a CLI that must exit, and `node --test` would otherwise hang forever — and ref'd only
|
||||
* while a job is in flight, so an in-progress thumbnail cannot be cut short by the process exiting.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, 'image-ops-worker.js');
|
||||
const IDLE_SHUTDOWN_MS = 60_000; // release the decoder heap (jimp + the WASM codecs) when quiet
|
||||
|
||||
let worker = null;
|
||||
let idleTimer = null;
|
||||
let inFlight = null; // { id, resolve, reject } — at most one, by design
|
||||
let inlineOnly = false; // set if a worker cannot be created at all; see runInline
|
||||
let nextId = 1;
|
||||
const queue = [];
|
||||
|
||||
function clearIdleTimer() {
|
||||
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
||||
}
|
||||
|
||||
function scheduleIdleShutdown() {
|
||||
clearIdleTimer();
|
||||
if (!worker || inFlight || queue.length) return;
|
||||
idleTimer = setTimeout(() => {
|
||||
idleTimer = null;
|
||||
if (worker && !inFlight && !queue.length) { const w = worker; worker = null; w.terminate(); }
|
||||
}, IDLE_SHUTDOWN_MS);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
|
||||
// Reject everything outstanding. Called when the worker dies underneath us — a crash means OOM or
|
||||
// a bug, not a bad image (image-ops-worker catches decode failures and replies normally), so there
|
||||
// is nothing to usefully retry and callers already treat a rejection as "no metadata".
|
||||
function failAll(reason) {
|
||||
const dead = [inFlight, ...queue].filter(Boolean);
|
||||
inFlight = null;
|
||||
queue.length = 0;
|
||||
for (const job of dead) job.reject(new Error(reason));
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker) return worker;
|
||||
const { Worker } = require('worker_threads');
|
||||
worker = new Worker(WORKER_PATH);
|
||||
worker.unref();
|
||||
worker.on('message', (msg) => {
|
||||
const job = inFlight;
|
||||
if (!job || job.id !== msg.id) return; // a reply from a terminated generation; ignore
|
||||
inFlight = null;
|
||||
if (msg.ok) job.resolve(msg.result); else job.reject(new Error(msg.error));
|
||||
pump();
|
||||
});
|
||||
worker.on('error', (err) => { worker = null; failAll(`image worker failed: ${err.message}`); });
|
||||
worker.on('exit', (code) => {
|
||||
worker = null;
|
||||
if (inFlight || queue.length) failAll(`image worker exited (code ${code})`);
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (inFlight) return;
|
||||
if (!queue.length) { worker?.unref(); scheduleIdleShutdown(); return; }
|
||||
clearIdleTimer();
|
||||
inFlight = queue.shift();
|
||||
const w = ensureWorker();
|
||||
w.ref(); // a job is running: hold the process open until it finishes
|
||||
w.postMessage(inFlight.job);
|
||||
}
|
||||
|
||||
// Last resort: if worker_threads cannot give us a thread at all, do the work in-process rather
|
||||
// than refuse to thumbnail. Stalls the loop — that is the bug this module exists to avoid — so it
|
||||
// is announced rather than silent.
|
||||
async function runInline(job) {
|
||||
const core = require('./image-ops-core');
|
||||
return job.op === 'metadata'
|
||||
? core.metadata(job.src)
|
||||
: core.writeThumbnail(job.src, job.dest, job.width, job.quality);
|
||||
}
|
||||
|
||||
function submit(job) {
|
||||
if (inlineOnly) return runInline(job);
|
||||
job.id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
ensureWorker();
|
||||
} catch (err) {
|
||||
inlineOnly = true;
|
||||
console.warn(`[image-ops] no worker thread (${err.message}) — decoding in-process, which blocks the event loop`);
|
||||
return resolve(runInline(job));
|
||||
}
|
||||
queue.push({ id: job.id, job, resolve, reject });
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
/* Display dimensions, shaped like the sharp metadata callers destructure. See image-ops-core. */
|
||||
function metadata(src) {
|
||||
return submit({ op: 'metadata', src });
|
||||
}
|
||||
|
||||
/* Write a JPEG thumbnail `width` px wide, aspect preserved. Rotation is implicit in the decode. */
|
||||
function writeThumbnail(src, dest, width, quality = 70) {
|
||||
return submit({ op: 'writeThumbnail', src, dest, width, quality });
|
||||
}
|
||||
|
||||
/*
|
||||
* Both of the above from ONE decode -> { width, height, orientation, thumbnailWritten,
|
||||
* thumbnailError }. Prefer this wherever both are wanted: a decode here is the full ~1s of a 12MP
|
||||
* photo, not sharp's cheap header parse, so the pair costs double. See image-ops-core.
|
||||
*/
|
||||
function measureAndThumbnail(src, dest, width, quality = 70) {
|
||||
return submit({ op: 'measureAndThumbnail', src, dest, width, quality });
|
||||
}
|
||||
|
||||
/* Drop the worker now rather than waiting out the idle timer. For shutdown paths and tests. */
|
||||
async function shutdown() {
|
||||
clearIdleTimer();
|
||||
const w = worker;
|
||||
worker = null;
|
||||
if (w) await w.terminate();
|
||||
}
|
||||
|
||||
module.exports = { metadata, writeThumbnail, measureAndThumbnail, shutdown };
|
||||
39
server/lib/item-duration.js
Normal file
39
server/lib/item-duration.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use strict';
|
||||
|
||||
// #237: a video added to a playlist got the flat 10s default, so a 32s clip was cut off at
|
||||
// 10s unless the operator looked up the runtime and typed it in — per item, every time. The
|
||||
// content row already carries the probed length, so that becomes the default. Shared by
|
||||
// every playlist_items insert path (dashboard, device assign, group assign, agency portal,
|
||||
// schedules, public API) because the operator sees one product, not six routes.
|
||||
|
||||
const DEFAULT_ITEM_DURATION = 10;
|
||||
|
||||
// A probe that reports longer than this is a broken container (streams and truncated files
|
||||
// report absurd or near-infinite lengths), not a clip anyone means to schedule — honoring it
|
||||
// would park a display on one item for days with no obvious cause. 12h.
|
||||
const MAX_CONTENT_DURATION = 43200;
|
||||
|
||||
// The content's own length, or null when there isn't a trustworthy one: images, widgets,
|
||||
// YouTube and remote-URL rows carry no duration, and a failed ffprobe leaves null/0. Rounded
|
||||
// UP so a 31.7s clip gets 32 and not a 31 that clips the tail; whole seconds because the
|
||||
// Android player reads duration_sec with optInt (a fractional value silently truncates) and
|
||||
// an operator expects to see a round number in the duration box.
|
||||
function contentDefaultDuration(content) {
|
||||
const n = Number(content && content.duration_sec);
|
||||
if (!Number.isFinite(n) || n <= 0 || n > MAX_CONTENT_DURATION) return null;
|
||||
return Math.max(1, Math.ceil(n));
|
||||
}
|
||||
|
||||
// The duration to STORE on a new playlist_item. An explicit operator value always wins; the
|
||||
// content's own length is only a default for when none was given.
|
||||
//
|
||||
// Anything that isn't a usable number falls back rather than reaching the DB: a duration of
|
||||
// 0 (or NaN, from a client that sent a string) makes the players schedule a 0ms advance,
|
||||
// which self-loops and black-screens the TV (#widget zero-duration loop).
|
||||
function resolveItemDuration(requested, content) {
|
||||
const n = Number(requested);
|
||||
if (Number.isFinite(n) && n >= 1) return Math.floor(n);
|
||||
return contentDefaultDuration(content) ?? DEFAULT_ITEM_DURATION;
|
||||
}
|
||||
|
||||
module.exports = { resolveItemDuration, contentDefaultDuration, DEFAULT_ITEM_DURATION, MAX_CONTENT_DURATION };
|
||||
|
|
@ -55,6 +55,42 @@ function captureIdentity(data) {
|
|||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Absent is not a statement — the same rule applyCapabilities() enforces for the capability column.
|
||||
*
|
||||
* captureIdentity above coerces a MISSING platform to the literal 'unknown', and persistIdentity
|
||||
* used to write that straight over the stored value. One register from a client that doesn't send
|
||||
* the field — an older build after an OTA, a downgrade, anything pre-v4 — permanently erased the
|
||||
* panel's platform.
|
||||
*
|
||||
* That column is load-bearing, not decorative: player-capabilities.platformFamily() reads it to
|
||||
* pick a baseline. An erased Tizen panel falls through to the WEB baseline and is offered a volume
|
||||
* slider the .wgt has no handler for — the exact control BASELINE.tizen exists to hide — while an
|
||||
* erased BrightSign loses screen power and reboot and gains screenshots it cannot take.
|
||||
*
|
||||
* platform and client_type are preserved; client_version and contract_version are NOT. The split is
|
||||
* "physical fact" vs "property of the build currently installed": a panel does not stop being a
|
||||
* Tizen TV or a .wgt player, but its version and protocol level change with every OTA, and there
|
||||
* "we no longer know" is the truthful answer rather than a stale number.
|
||||
*
|
||||
* client_type earns its place because it is the SECOND signal platformFamily() reads ('wgt' => a
|
||||
* Tizen TV): preserving platform while letting client_type decay to 'legacy' would leave a panel
|
||||
* with no identifying signal at all.
|
||||
*
|
||||
* @param {object|null} stored the identity row currently in the DB
|
||||
* @param {object} incoming the freshly captured identity (mutated in place and returned)
|
||||
*/
|
||||
const IDENTITY_PLACEHOLDER = { platform: 'unknown', client_type: 'legacy' };
|
||||
function preserveKnownIdentity(stored, incoming) {
|
||||
if (!incoming || !stored) return incoming;
|
||||
for (const [field, placeholder] of Object.entries(IDENTITY_PLACEHOLDER)) {
|
||||
if (incoming[field] === placeholder && stored[field] && stored[field] !== placeholder) {
|
||||
incoming[field] = stored[field];
|
||||
}
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// A1 change-detection: has the (already-captured) identity changed vs what's stored? A genuine
|
||||
// reconnect with an unchanged identity (the common case) then does NO write. A never-stored device
|
||||
// (current null / all-NULL columns) or a real change (e.g. new client_version after an OTA) writes.
|
||||
|
|
@ -78,4 +114,4 @@ function sanitizeExitReason(reason, detail) {
|
|||
return { reason, detail: d };
|
||||
}
|
||||
|
||||
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };
|
||||
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, preserveKnownIdentity, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };
|
||||
|
|
|
|||
32
server/lib/media-tools.js
Normal file
32
server/lib/media-tools.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use strict';
|
||||
|
||||
// Availability probe for the external media binaries (ffmpeg/ffprobe) that video
|
||||
// thumbnail + duration extraction depends on. They are SYSTEM dependencies, not npm
|
||||
// ones, so a deployment can easily lack them — and content-ingest's best-effort
|
||||
// contract means every video then uploads fine but silently gets no thumbnail and
|
||||
// no duration. Probed once and cached: the answer can't change without an operator
|
||||
// installing packages, which comes with a restart anyway.
|
||||
//
|
||||
// Async on purpose: the first caller is server.js right after listen, and a hung
|
||||
// binary (NFS-mounted PATH shim, broken wrapper) must degrade to a late log line,
|
||||
// not block request serving on a freshly-bound port.
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
let cached = null;
|
||||
|
||||
function probeTool(bin) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(bin, ['-version'], { timeout: 5000 }, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
|
||||
function mediaToolStatus() {
|
||||
if (!cached) {
|
||||
cached = Promise.all([probeTool('ffmpeg'), probeTool('ffprobe')])
|
||||
.then(([ffmpeg, ffprobe]) => ({ ffmpeg, ffprobe }));
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
module.exports = { mediaToolStatus };
|
||||
471
server/lib/oidc-providers.js
Normal file
471
server/lib/oidc-providers.js
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Which identity providers this instance offers.
|
||||
*
|
||||
* Providers are resolved through ONE function on purpose. Instance-wide providers come from the
|
||||
* environment today; per-organization SSO will come from the database later, and when it does it
|
||||
* plugs in here rather than growing a second login path. The rest of the app only ever asks
|
||||
* "give me the provider called X" and never learns where the answer came from.
|
||||
*
|
||||
* ── Configuration ────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* OIDC_PROVIDERS=okta,authentik comma-separated slugs to enable
|
||||
* OIDC_OKTA_ISSUER=https://example.okta.com
|
||||
* OIDC_OKTA_CLIENT_ID=...
|
||||
* OIDC_OKTA_CLIENT_SECRET=... optional — PKCE means a public client works
|
||||
* OIDC_OKTA_NAME=Okta optional button label
|
||||
* OIDC_OKTA_SCOPES=openid email profile optional
|
||||
*
|
||||
* Google and Microsoft are ordinary OIDC providers and are registered automatically from the
|
||||
* variables the README has always documented (GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID +
|
||||
* MICROSOFT_TENANT_ID), so an existing deployment keeps working without editing anything. They get
|
||||
* no special code path — the only difference is that their issuer is filled in for you.
|
||||
*/
|
||||
|
||||
const GOOGLE_ISSUER = 'https://accounts.google.com';
|
||||
const DEFAULT_SCOPES = 'openid email profile';
|
||||
|
||||
/** A slug has to be safe in a URL path and in an env var name. */
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/;
|
||||
|
||||
/*
|
||||
* `local` is what users.auth_provider says for a password account, so a provider by that name would
|
||||
* make every federated login look like a password login to the linking rules — and would put a NULL
|
||||
* password_hash on rows that POST /login then feeds straight to bcrypt.compareSync. Reserved rather
|
||||
* than merely discouraged.
|
||||
*/
|
||||
const RESERVED_SLUGS = new Set(['local', 'recovery']);
|
||||
|
||||
function envKey(slug, suffix) {
|
||||
return `OIDC_${slug.toUpperCase().replace(/-/g, '_')}_${suffix}`;
|
||||
}
|
||||
|
||||
function fromEnv(env, slug) {
|
||||
const issuer = (env[envKey(slug, 'ISSUER')] || '').trim().replace(/\/+$/, '');
|
||||
const clientId = (env[envKey(slug, 'CLIENT_ID')] || '').trim();
|
||||
if (!issuer || !clientId) return null;
|
||||
return {
|
||||
slug,
|
||||
name: (env[envKey(slug, 'NAME')] || '').trim() || slug.replace(/[-_]/g, ' '),
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret: (env[envKey(slug, 'CLIENT_SECRET')] || '').trim() || null,
|
||||
scopes: (env[envKey(slug, 'SCOPES')] || '').trim() || DEFAULT_SCOPES,
|
||||
// Escape hatch for an IdP that verifies addresses but does not say so in the token. Off unless
|
||||
// the operator sets it, and it only ever covers an ABSENT claim — see emailIsVerified().
|
||||
assumeEmailVerified: /^(1|true|yes)$/i.test((env[envKey(slug, 'ASSUME_EMAIL_VERIFIED')] || '').trim()),
|
||||
source: 'env',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* May this provider's assertion of `email` be treated as verified?
|
||||
*
|
||||
* The login callback used to demand `claims.email_verified === true` outright. That is correct for a
|
||||
* provider a CUSTOMER configured — such a provider is chosen by the party it vouches for, so
|
||||
* anything it merely asserts is worth nothing — but it made Microsoft sign-in impossible, because
|
||||
* **Entra ID v2 does not emit the claim at all**. Every Entra login authenticated successfully and
|
||||
* was then refused with `email_unverified`.
|
||||
*
|
||||
* The distinction that resolves it: `users.email_verified` is OUR state and this is the IdP's claim.
|
||||
* Whether an address is trustworthy is a decision about WHO WE TRUSTED, not a field we can insist a
|
||||
* provider populate. An instance-wide provider was chosen by the operator — the same trust that
|
||||
* already exempts it from domain confinement — and the Microsoft entry is additionally pinned to one
|
||||
* tenant GUID, so only that directory can issue tokens for it.
|
||||
*
|
||||
* An organization's own provider may assume too, but only once it has DNS-verified a domain — the
|
||||
* callback confines it to those domains, so it can only ever speak for names it proved it controls.
|
||||
* Requiring the claim from it as well meant a customer's Entra tenant went green on domain
|
||||
* verification and then failed the login anyway.
|
||||
*
|
||||
* Three limits keep this from becoming the hole the strict check was closing:
|
||||
* - an EXPLICIT `email_verified: false` is always refused. Assuming only ever covers an omitted
|
||||
* claim, never a provider actively saying the address is unverified;
|
||||
* - for an org provider it is DERIVED from proof (`verified.length > 0`) and is never a column, so
|
||||
* a customer cannot switch it on for themselves;
|
||||
* - domain confinement is unchanged and still runs first, so an org provider that assumes still
|
||||
* cannot assert an address outside a domain it has proven.
|
||||
*/
|
||||
function emailIsVerified(claims, provider) {
|
||||
const asserted = (claims || {}).email_verified;
|
||||
if (asserted === true) return true;
|
||||
if (asserted === undefined || asserted === null) return !!(provider && provider.assumeEmailVerified);
|
||||
return false; // explicit false, or anything else the provider chose to send
|
||||
}
|
||||
|
||||
/**
|
||||
* Every provider this instance offers, in a stable order.
|
||||
*
|
||||
* ⚠️ Never returns clientSecret to a caller that only wants to draw buttons — see publicList().
|
||||
*/
|
||||
function list(env = process.env) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Back-compat: the two providers the README documented before generic OIDC existed.
|
||||
const googleId = (env.GOOGLE_CLIENT_ID || '').trim();
|
||||
if (googleId) {
|
||||
out.push({
|
||||
slug: 'google',
|
||||
name: 'Google',
|
||||
issuer: GOOGLE_ISSUER,
|
||||
clientId: googleId,
|
||||
clientSecret: (env.GOOGLE_CLIENT_SECRET || '').trim() || null,
|
||||
scopes: DEFAULT_SCOPES,
|
||||
// Google DOES send email_verified. Nothing to assume, so it stays strict.
|
||||
assumeEmailVerified: false,
|
||||
source: 'env',
|
||||
});
|
||||
seen.add('google');
|
||||
}
|
||||
|
||||
const msId = (env.MICROSOFT_CLIENT_ID || '').trim();
|
||||
if (msId) {
|
||||
/*
|
||||
* ⚠️ A TENANT GUID IS REQUIRED. `common` and `organizations` are refused, for two reasons that
|
||||
* point the same way.
|
||||
*
|
||||
* It does not work: Microsoft's multi-tenant metadata advertises
|
||||
* `https://login.microsoftonline.com/{tenantid}/v2.0` — a literal template — so the issuer can
|
||||
* never equal the configured URL and every login fails at /start regardless.
|
||||
*
|
||||
* And the obvious patch is dangerous: loosening the `iss` comparison to accept the template
|
||||
* means accepting tokens from EVERY Azure tenant, which is nOAuth — an admin of any tenant can
|
||||
* set an arbitrary, unverified `email` on one of their own users and be issued a session as that
|
||||
* address here. Doing multi-tenant Microsoft safely needs per-tenant pinning (validate `tid`
|
||||
* against an allowlist and key the account on `oid`+`tid`, not on email), which is a feature,
|
||||
* not a relaxed regex.
|
||||
*
|
||||
* So: refuse loudly at boot rather than ship a login that either never works or works too well.
|
||||
*/
|
||||
const rawTenant = (env.MICROSOFT_TENANT_ID || '').trim().toLowerCase();
|
||||
if (!rawTenant || ['common', 'organizations', 'consumers'].includes(rawTenant)) {
|
||||
if (!list._warned) {
|
||||
console.warn('[sso] MICROSOFT_CLIENT_ID is set but MICROSOFT_TENANT_ID is missing or multi-tenant '
|
||||
+ `(${rawTenant || 'unset'}). Microsoft sign-in is DISABLED: set your tenant GUID. See README.`);
|
||||
list._warned = true;
|
||||
}
|
||||
seen.add('microsoft');
|
||||
} else {
|
||||
out.push({
|
||||
slug: 'microsoft',
|
||||
name: 'Microsoft',
|
||||
// A tenant GUID narrows the issuer to that tenant, so a token from any other tenant fails
|
||||
// the `iss` check instead of being quietly accepted.
|
||||
issuer: `https://login.microsoftonline.com/${rawTenant}/v2.0`,
|
||||
clientId: msId,
|
||||
clientSecret: (env.MICROSOFT_CLIENT_SECRET || '').trim() || null,
|
||||
scopes: DEFAULT_SCOPES,
|
||||
/*
|
||||
* Entra ID v2 never sends email_verified, so demanding it refused every Microsoft login.
|
||||
* Safe here specifically because this entry is operator-chosen AND pinned to one tenant
|
||||
* GUID above: only that directory can issue a token whose `iss` matches. An explicit
|
||||
* email_verified:false is still refused — see emailIsVerified().
|
||||
*/
|
||||
assumeEmailVerified: true,
|
||||
source: 'env',
|
||||
});
|
||||
seen.add('microsoft');
|
||||
}
|
||||
}
|
||||
|
||||
for (const raw of String(env.OIDC_PROVIDERS || '').split(',')) {
|
||||
const slug = raw.trim().toLowerCase();
|
||||
if (!slug || seen.has(slug)) continue;
|
||||
if (!SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug)) continue; // ignore rather than crash a boot over a typo
|
||||
const p = fromEnv(env, slug);
|
||||
if (p) { out.push(p); seen.add(slug); }
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One provider by slug, or null. This is the seam per-org SSO will extend. */
|
||||
function get(slug, env = process.env) {
|
||||
if (!slug || !SLUG_RE.test(String(slug))) return null;
|
||||
const fromEnvList = list(env).find((p) => p.slug === slug);
|
||||
if (fromEnvList) return fromEnvList;
|
||||
// Instance providers win a name clash, which cannot happen in practice (org slugs are random)
|
||||
// but decides it deterministically if it ever did.
|
||||
return getOrgProvider(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the login page is allowed to know: enough to draw a button and nothing else.
|
||||
* No client ids, because the browser never talks to the provider directly any more — the redirect
|
||||
* is built server-side, so there is nothing for the page to do with one.
|
||||
*/
|
||||
function publicList(env = process.env) {
|
||||
return list(env).map((p) => ({ slug: p.slug, name: p.name }));
|
||||
}
|
||||
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* Per-organization providers.
|
||||
*
|
||||
* Loaded lazily so this module stays usable (and testable) without a database — the env-only paths
|
||||
* above never touch it. An org provider is an ordinary provider once loaded: the login flow cannot
|
||||
* tell the difference, which is the whole point of resolving everything through get().
|
||||
*/
|
||||
|
||||
let _db = null;
|
||||
function db() {
|
||||
if (_db === null) {
|
||||
try { _db = require('../db/database').db; } catch { _db = false; }
|
||||
}
|
||||
return _db || null;
|
||||
}
|
||||
|
||||
function rowToProvider(row, secretbox) {
|
||||
// Resolved once: it decides both which addresses this provider may assert and, below, whether it
|
||||
// has proven anything at all.
|
||||
const verified = verifiedDomainsFor(row.id);
|
||||
return {
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
issuer: String(row.issuer).replace(/\/+$/, ''),
|
||||
clientId: row.client_id,
|
||||
/*
|
||||
* Fail CLOSED. secretbox.decrypt returns null when the key has rotated, which silently turned a
|
||||
* confidential client into a public one — the login then fails at the provider with an error
|
||||
* nobody can act on, while the admin screen still says "a secret is set".
|
||||
*/
|
||||
clientSecret: row.client_secret_enc
|
||||
? (secretbox.decrypt(row.client_secret_enc) ?? (() => { throw new Error('client secret could not be decrypted — re-enter it'); })())
|
||||
: null,
|
||||
scopes: row.scopes || DEFAULT_SCOPES,
|
||||
/*
|
||||
* DERIVED from proof, never read from a column.
|
||||
*
|
||||
* Entra ID v2 omits email_verified, so demanding it refused every customer who brought their own
|
||||
* Microsoft tenant — the domain went green and the login still failed. Requiring a claim
|
||||
* Microsoft does not send is not a security control, it is an outage.
|
||||
*
|
||||
* What makes it safe to stop requiring it is the proof that already gates this provider: the
|
||||
* callback confines it to DNS-verified domains, and an address is only reached here after
|
||||
* passing that. Whoever controls a domain's DNS controls its mail, which is the same trust that
|
||||
* makes a verification link meaningful in the first place.
|
||||
*
|
||||
* So the assumption is tied to having proven SOMETHING. A provider with no verified domain
|
||||
* assumes nothing — belt and braces, because emailAllowedForProvider already refuses it (an
|
||||
* empty allow-list matches no domain), and this way a future refactor that reorders those checks
|
||||
* cannot silently widen it.
|
||||
*
|
||||
* ⚠️ Still never a column. An org must not be able to switch this on for itself; it is a
|
||||
* consequence of DNS proof, not a setting.
|
||||
*/
|
||||
assumeEmailVerified: verified.length > 0,
|
||||
source: 'org',
|
||||
organizationId: row.organization_id,
|
||||
/*
|
||||
* ⚠️ VERIFIED domains only — never org_sso_providers.email_domains.
|
||||
*
|
||||
* That column is what an admin typed. This is what they PROVED, by publishing a record in the
|
||||
* domain's own DNS, and it is the only thing the login callback may confine an assertion to.
|
||||
* Reading the typed column here would reduce the whole verification feature to a decoration:
|
||||
* a tenant could type any company's domain and immediately assert addresses in it.
|
||||
*/
|
||||
emailDomains: verified.join(','),
|
||||
};
|
||||
}
|
||||
|
||||
/** The domains a provider has actually proved it controls. */
|
||||
function verifiedDomainsFor(providerId) {
|
||||
const conn = db();
|
||||
if (!conn) return [];
|
||||
try {
|
||||
return conn.prepare('SELECT domain FROM org_sso_domains WHERE provider_id = ? AND verified_at IS NOT NULL')
|
||||
.all(providerId).map((r) => r.domain);
|
||||
} catch (e) {
|
||||
if (/no such table/i.test(e.message)) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** One org provider by its (globally unique) slug, or null. */
|
||||
function getOrgProvider(slug) {
|
||||
const conn = db();
|
||||
if (!conn || !slug || !SLUG_RE.test(String(slug))) return null;
|
||||
try {
|
||||
const row = conn.prepare('SELECT * FROM org_sso_providers WHERE slug = ? AND enabled = 1').get(String(slug));
|
||||
if (!row) return null;
|
||||
return rowToProvider(row, require('./secretbox'));
|
||||
} catch (e) {
|
||||
/*
|
||||
* Only "the table is not there yet" is a null. This catch used to swallow EVERYTHING, which
|
||||
* turned a secret that could not be decrypted back into a silent success — the exact failure the
|
||||
* fail-closed check above exists to prevent. Anything else propagates so it is logged and the
|
||||
* login fails loudly.
|
||||
*/
|
||||
if (/no such table/i.test(e.message)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who owns a provider slug — without decrypting anything, and regardless of whether it is enabled.
|
||||
*
|
||||
* The linking rules need to know which ORGANIZATION established an account, not how to talk to its
|
||||
* provider, and asking get() for that has two problems: it fails closed on an undecryptable secret
|
||||
* (right for a login, wrong for an ownership question) and it hides disabled rows, which still own
|
||||
* the accounts they created.
|
||||
*
|
||||
* null means "nothing here owns that slug" — either it never existed or the provider has since been
|
||||
* deleted, and those are deliberately the same answer.
|
||||
*/
|
||||
function ownerOf(slug) {
|
||||
if (!slug || !SLUG_RE.test(String(slug))) return null;
|
||||
if (list().some((p) => p.slug === slug)) return { source: 'env', organizationId: null };
|
||||
const conn = db();
|
||||
if (!conn) return null;
|
||||
try {
|
||||
const row = conn.prepare('SELECT organization_id FROM org_sso_providers WHERE slug = ?').get(String(slug));
|
||||
return row ? { source: 'org', organizationId: row.organization_id } : null;
|
||||
} catch (e) {
|
||||
if (/no such table/i.test(e.message)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which provider, if any, owns an email address.
|
||||
*
|
||||
* Domain routing is what makes per-org SSO usable: a customer's staff type their work address and
|
||||
* are sent to their own identity provider rather than being asked for a password they do not have.
|
||||
*
|
||||
* ⚠️ Matched on the domain ONLY, never on whether the address exists. Answering "yes, that domain
|
||||
* uses SSO" tells an attacker nothing they could not learn from the customer's website; answering
|
||||
* "yes, that USER exists" would be an account-enumeration oracle on the login page.
|
||||
*/
|
||||
function forEmail(email) {
|
||||
const conn = db();
|
||||
if (!conn) return null;
|
||||
const at = String(email || '').lastIndexOf('@');
|
||||
if (at === -1) return null;
|
||||
const domain = String(email).slice(at + 1).toLowerCase().trim();
|
||||
if (!domain) return null;
|
||||
try {
|
||||
/*
|
||||
* Routing is driven by the VERIFIED domain table, not by the text an admin typed, and the JOIN
|
||||
* is what enforces it — an unverified claim cannot send anyone anywhere.
|
||||
*
|
||||
* No ORDER BY: `domain` is UNIQUE, so at most one row can match and there is no tie to break.
|
||||
* An earlier version ordered here and the comment claimed it decided a race; it decided
|
||||
* nothing, and saying so invited someone to rely on it.
|
||||
*/
|
||||
const row = conn.prepare(`
|
||||
SELECT p.* FROM org_sso_domains d
|
||||
JOIN org_sso_providers p ON p.id = d.provider_id
|
||||
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1
|
||||
`).get(domain);
|
||||
if (row) return rowToProvider(row, require('./secretbox'));
|
||||
} catch (e) {
|
||||
// Only a missing table is a null — anything else (a secret that will not decrypt, a schema
|
||||
// drift) must surface rather than silently answering "this domain has no SSO", which is how a
|
||||
// fail-closed guarantee turns back into a fail-open one.
|
||||
if (!/no such table/i.test(e.message)) throw e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this address inside an organization that REQUIRES its identity provider?
|
||||
*
|
||||
* Only a VERIFIED domain can compel anyone: an org must not be able to switch off password login
|
||||
* for a domain it merely typed, which would be a denial-of-service against a company it has nothing
|
||||
* to do with. Enabled providers only, for the same reason a disabled provider routes nobody.
|
||||
*/
|
||||
function ssoOnlyForEmail(email) {
|
||||
const conn = db();
|
||||
if (!conn) return null;
|
||||
const at = String(email || '').lastIndexOf('@');
|
||||
if (at === -1) return null;
|
||||
// A trailing root dot is the same domain; `acme.test.` slipped the match and let someone
|
||||
// register at an SSO-only domain (a distinct string, so no squat — but a hole in the gate).
|
||||
const domain = String(email).slice(at + 1).toLowerCase().trim().replace(/\.+$/, '');
|
||||
if (!domain) return null;
|
||||
try {
|
||||
return conn.prepare(`
|
||||
SELECT o.id AS organization_id, o.name AS organization_name, p.slug
|
||||
FROM org_sso_domains d
|
||||
JOIN org_sso_providers p ON p.id = d.provider_id
|
||||
JOIN organizations o ON o.id = d.organization_id
|
||||
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1 AND o.sso_only = 1
|
||||
`).get(domain) || null;
|
||||
} catch (e) {
|
||||
/*
|
||||
* ⚠️ FAIL CLOSED. This used to swallow `no such column` and return null — and null means "not
|
||||
* SSO-only", i.e. password login proceeds. It is the single control stopping a password from
|
||||
* bypassing a customer's identity provider, so a schema problem must never be the thing that
|
||||
* quietly switches it off. The sibling forEmail() carries the same warning for the same reason.
|
||||
*
|
||||
* `no such table` on the DOMAINS table is different and genuinely means "this instance has no
|
||||
* per-org SSO at all", so it stays a null.
|
||||
*/
|
||||
/*
|
||||
* "The feature is not installed" and "the schema drifted" are different answers.
|
||||
*
|
||||
* A missing per-org SSO table, or no organizations table at all, means this instance has no
|
||||
* per-organization SSO — nothing is being bypassed, so null is correct and a single-tenant
|
||||
* install must keep working. A missing sso_only COLUMN on a table that does exist is drift, and
|
||||
* that is the case that must never quietly answer "not required".
|
||||
*/
|
||||
if (/no such table: (org_sso_domains|org_sso_providers|organizations|organization_members)/i.test(e.message)) return null;
|
||||
console.error('[sso] could not determine SSO-only status, refusing password login:', e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must THIS USER use single sign-on?
|
||||
*
|
||||
* ⚠️ Membership, not just the address. ssoOnlyForEmail() answers about a DOMAIN, and a review used
|
||||
* that gap to walk straight in: any account in the tenant whose address sits outside the verified
|
||||
* domains kept password login — a contractor, an MSP, the one address nobody remembered. Worse, it
|
||||
* could be manufactured on demand, because an org admin can create a local password account at any
|
||||
* address and bind it to their workspace. Enforcing on the domain alone protects the domain; it
|
||||
* does not protect the ORGANIZATION, which is what the setting claims to do.
|
||||
*
|
||||
* So both are asked: the address's domain (which catches people who are not members yet) and every
|
||||
* organization the user actually belongs to.
|
||||
*/
|
||||
function ssoOnlyForUser(user) {
|
||||
if (!user) return null;
|
||||
const byDomain = ssoOnlyForEmail(user.email);
|
||||
if (byDomain) return byDomain;
|
||||
|
||||
const conn = db();
|
||||
if (!conn) return null;
|
||||
try {
|
||||
/*
|
||||
* ⚠️ WORKSPACE membership, not just organization_members.
|
||||
*
|
||||
* Almost nobody is in `organization_members`: only three places write it (creating an org,
|
||||
* an org-SSO login, a platform admin creating an org) and nothing ever deletes a row. Every
|
||||
* INVITED user, every admin-created account and every workspace assignment lands in
|
||||
* `workspace_members` and nowhere else — so an earlier version of this check covered org
|
||||
* owners and people who had already used SSO, which is exactly the set the domain check
|
||||
* already caught. A review invited an outside address into an SSO-only tenant and kept
|
||||
* password login, then used it to invite more.
|
||||
*/
|
||||
return conn.prepare(`
|
||||
SELECT o.id AS organization_id, o.name AS organization_name
|
||||
FROM organizations o
|
||||
WHERE o.sso_only = 1
|
||||
AND (EXISTS (SELECT 1 FROM organization_members m WHERE m.organization_id = o.id AND m.user_id = ?)
|
||||
OR EXISTS (SELECT 1 FROM workspace_members wm
|
||||
JOIN workspaces w ON w.id = wm.workspace_id
|
||||
WHERE w.organization_id = o.id AND wm.user_id = ?))
|
||||
LIMIT 1
|
||||
`).get(user.id, user.id) || null;
|
||||
} catch (e) {
|
||||
if (/no such table: (organization_members|organizations|workspace_members|workspaces)/i.test(e.message)) return null;
|
||||
throw e; // drift on a table that exists — fail closed; the caller refuses the login
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list, get, publicList, getOrgProvider, ownerOf, forEmail,
|
||||
ssoOnlyForEmail, ssoOnlyForUser, emailIsVerified, DEFAULT_SCOPES, SLUG_RE,
|
||||
};
|
||||
332
server/lib/oidc.js
Normal file
332
server/lib/oidc.js
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* OpenID Connect — discovery, key handling and ID-token verification.
|
||||
*
|
||||
* This exists because the previous "OAuth" support verified nothing that mattered. The Google path
|
||||
* asked Google's tokeninfo endpoint whether an ACCESS token was valid and then trusted the email in
|
||||
* the reply; the Microsoft path handed a bearer token to Graph /me and trusted that. Neither ever
|
||||
* checked WHO THE TOKEN WAS ISSUED FOR, and an access token is not a proof of identity — it is a
|
||||
* bearer credential for some resource, minted for some application, and Graph will happily describe
|
||||
* the user behind a token issued to somebody else's app. Any site a user signs into that asks for
|
||||
* `email` or `User.Read` could replay that token here and be issued a session as that user.
|
||||
*
|
||||
* So identity now comes from an ID TOKEN and nothing else, and the token has to survive:
|
||||
*
|
||||
* signature against the provider's published JWKS, restricted to asymmetric algorithms
|
||||
* iss exactly the issuer discovery advertised
|
||||
* aud contains our client_id (and azp === client_id when the token carries one)
|
||||
* exp/nbf inside a small clock skew
|
||||
* nonce equal to the one WE generated for this login, which is what stops a token
|
||||
* obtained elsewhere — even a correctly-audienced one — being replayed here
|
||||
*
|
||||
* Deliberately dependency-free beyond `jsonwebtoken`: Node can import a JWK straight into a
|
||||
* KeyObject, so there is no need for jwks-rsa and no second opinion about what a key is.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const net = require('net');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/*
|
||||
* `alg: "none"` is the oldest JWT attack there is, and HMAC is nearly as bad here: an HS256 token is
|
||||
* verified with a SHARED SECRET, and the only "key" we have for a provider is its PUBLIC one — which
|
||||
* an attacker also has, and could sign with. Only asymmetric families are ever acceptable.
|
||||
*/
|
||||
const ALLOWED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512'];
|
||||
|
||||
// Providers rotate keys and publish new ones ahead of use, so a short cache is safe and a miss is
|
||||
// cheap. Discovery changes far less often but is cached the same way for one reason: a provider
|
||||
// outage should not be able to stall every login for as long as it lasts.
|
||||
const DISCOVERY_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const JWKS_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
const discoveryCache = new Map(); // issuer -> { at, doc }
|
||||
const jwksCache = new Map(); // jwks_uri -> { at, keys }
|
||||
|
||||
/*
|
||||
* Every URL this module fetches is ultimately chosen by whoever configured the provider — and since
|
||||
* per-org SSO, that is a CUSTOMER, not the operator. Discovery, JWKS and the token endpoint are
|
||||
* therefore server-side request forgery primitives unless they are constrained.
|
||||
*
|
||||
* Two rules, both cheap:
|
||||
* https only — an http:// target is a plaintext credential leak as well as a way to reach
|
||||
* services that never expected a request from inside the network.
|
||||
* public hosts only — loopback, RFC1918, CGNAT, link-local (169.254.169.254 is cloud metadata),
|
||||
* multicast and reserved ranges, in BOTH address families, including the
|
||||
* IPv4-mapped IPv6 forms that a prefix match misses.
|
||||
*
|
||||
* ⚠️ This is a literal-address check, not full SSRF protection: a hostname that RESOLVES to a
|
||||
* private address still passes, because refusing that needs resolve-then-pin plumbing that Node's
|
||||
* fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS".
|
||||
* README.md documents this limitation under per-organization SSO.
|
||||
*/
|
||||
/*
|
||||
* Addresses are parsed as ADDRESSES and compared by range. This started life as a prefix regex,
|
||||
* which was wrong in both directions: it missed `[::ffff:127.0.0.1]` — the entire IPv4 space
|
||||
* re-encoded, which WHATWG URL normalises to `[::ffff:7f00:1]` so no dotted-quad prefix can match,
|
||||
* and a review reached a loopback service straight through it — while also matching plain TEXT, so
|
||||
* every hostname beginning "fc" or "fd" was refused (fcm.googleapis.com, fcps.edu).
|
||||
*/
|
||||
const BLOCKED_V4 = [
|
||||
['0.0.0.0', 8], // "this network"
|
||||
['10.0.0.0', 8], // RFC1918
|
||||
['100.64.0.0', 10], // CGNAT / Tailscale
|
||||
['127.0.0.0', 8], // loopback
|
||||
['169.254.0.0', 16], // link-local — 169.254.169.254 is cloud metadata
|
||||
['172.16.0.0', 12], // RFC1918
|
||||
['192.0.0.0', 24], // IETF protocol assignments
|
||||
['192.168.0.0', 16], // RFC1918
|
||||
['198.18.0.0', 15], // benchmarking
|
||||
['224.0.0.0', 4], // multicast
|
||||
['240.0.0.0', 4], // reserved
|
||||
];
|
||||
|
||||
const v4ToInt = (ip) => ip.split('.').reduce((acc, o) => (acc * 256) + Number(o), 0);
|
||||
|
||||
function isBlockedV4(ip) {
|
||||
const addr = v4ToInt(ip);
|
||||
return BLOCKED_V4.some(([base, bits]) => {
|
||||
const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0;
|
||||
return (addr & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isBlockedV6(ip) {
|
||||
const low = ip.toLowerCase();
|
||||
// An IPv4-mapped or IPv4-compatible address is an IPv4 address wearing a hat — judge the IPv4.
|
||||
const mapped = low.match(/^::(ffff:)?(\d+\.\d+\.\d+\.\d+)$/)
|
||||
|| low.match(/^::(ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (mapped) {
|
||||
if (mapped[2] && mapped[2].includes('.')) return isBlockedV4(mapped[2]);
|
||||
const hi = parseInt(mapped[2], 16), lo = parseInt(mapped[3], 16);
|
||||
return isBlockedV4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.'));
|
||||
}
|
||||
if (low === '::' || low === '::1') return true; // unspecified (= loopback on Linux), loopback
|
||||
if (/^f[cd]/.test(low)) return true; // fc00::/7 unique-local
|
||||
if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local
|
||||
if (/^ff/.test(low)) return true; // multicast
|
||||
return false;
|
||||
}
|
||||
|
||||
function assertFetchable(url) {
|
||||
let u;
|
||||
try { u = new URL(url); } catch { throw new Error(`not a URL: ${url}`); }
|
||||
if (u.protocol !== 'https:') throw new Error('provider URLs must use https');
|
||||
|
||||
/*
|
||||
* A trailing root dot is a legal, fully-qualified spelling of the same name, and WHATWG URL keeps
|
||||
* it — so `https://localhost./` matched neither alternative below and was ALLOWED. The parser
|
||||
* normalises the literal-IP forms itself (`127.0.0.1.` becomes `127.0.0.1`), so only the name
|
||||
* form slipped, and on a resolver that synthesizes `localhost.` it resolves to loopback.
|
||||
*/
|
||||
const host = u.hostname.replace(/\.$/, '');
|
||||
// URL keeps IPv6 literals in brackets; net.isIP does not want them.
|
||||
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
||||
const family = net.isIP(bare);
|
||||
|
||||
const blocked = family === 4 ? isBlockedV4(bare)
|
||||
: family === 6 ? isBlockedV6(bare)
|
||||
: /^(localhost|.*\.localhost)$/i.test(host);
|
||||
|
||||
if (blocked) throw new Error('provider host is not publicly routable');
|
||||
return u;
|
||||
}
|
||||
|
||||
/** fetch with a timeout, because a hanging IdP must not hang a login forever. */
|
||||
async function getJson(url) {
|
||||
assertFetchable(url);
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
/*
|
||||
* redirect: 'manual' — following redirects would let an allowlisted host bounce us to a blocked
|
||||
* one, which defeats the check above entirely. A provider that redirects its own well-known
|
||||
* document is misconfigured, and saying so is more useful than quietly following it.
|
||||
*/
|
||||
const res = await fetch(url, { signal: ctl.signal, redirect: 'manual' });
|
||||
if (res.status >= 300 && res.status < 400) throw new Error(`${url} redirected; provider URLs must be final`);
|
||||
if (!res.ok) throw new Error(`${url} responded ${res.status}`);
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's own description of itself.
|
||||
*
|
||||
* ⚠️ The discovered `issuer` is checked against the configured one. Discovery is fetched over TLS
|
||||
* from a URL derived from the issuer, so this is belt-and-braces — but a provider whose document
|
||||
* claims a DIFFERENT issuer is either misconfigured or hostile, and either way its tokens must not
|
||||
* be accepted under a name it does not own.
|
||||
*/
|
||||
async function discover(issuer) {
|
||||
const key = String(issuer).replace(/\/+$/, '');
|
||||
const hit = discoveryCache.get(key);
|
||||
if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.doc;
|
||||
|
||||
const url = `${key}/.well-known/openid-configuration`;
|
||||
const doc = await getJson(url);
|
||||
|
||||
const advertised = String(doc.issuer || '').replace(/\/+$/, '');
|
||||
if (advertised !== key) {
|
||||
throw new Error(`discovery issuer mismatch: configured ${key}, document says ${doc.issuer}`);
|
||||
}
|
||||
for (const required of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
|
||||
if (!doc[required]) throw new Error(`discovery for ${key} is missing ${required}`);
|
||||
}
|
||||
|
||||
discoveryCache.set(key, { at: Date.now(), doc });
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signing key for one token.
|
||||
*
|
||||
* An unknown `kid` forces ONE refresh: that is the normal shape of a key rotation, and refusing to
|
||||
* refetch would fail every login until the cache expired. It is bounded to one refresh per call so
|
||||
* a token quoting nonsense cannot be used to hammer the provider.
|
||||
*/
|
||||
async function keyForKid(jwksUri, kid) {
|
||||
let entry = jwksCache.get(jwksUri);
|
||||
const fresh = entry && Date.now() - entry.at < JWKS_TTL_MS;
|
||||
|
||||
if (!fresh || !entry.keys.some((k) => k.kid === kid)) {
|
||||
const doc = await getJson(jwksUri);
|
||||
entry = { at: Date.now(), keys: Array.isArray(doc.keys) ? doc.keys : [] };
|
||||
jwksCache.set(jwksUri, entry);
|
||||
}
|
||||
|
||||
const jwk = entry.keys.find((k) => k.kid === kid)
|
||||
// A provider with exactly one key may omit kid entirely; anything ambiguous is refused rather
|
||||
// than guessed, because "try each key until one verifies" is how you accept a key you did not mean to.
|
||||
|| (!kid && entry.keys.length === 1 ? entry.keys[0] : null);
|
||||
if (!jwk) throw new Error(`no signing key for kid ${kid || '(none)'}`);
|
||||
|
||||
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an ID token and return its claims.
|
||||
*
|
||||
* `nonce` is REQUIRED by this function even though the spec makes it conditional. Every flow here
|
||||
* is a browser login we initiated, so we always have one to compare — and it is the single check
|
||||
* that distinguishes "a token minted for us, now" from "a token minted for us at some point,
|
||||
* captured, and replayed".
|
||||
*/
|
||||
async function verifyIdToken(idToken, { issuer, clientId, nonce }) {
|
||||
if (!idToken || typeof idToken !== 'string') throw new Error('no id_token');
|
||||
if (!nonce) throw new Error('no nonce to verify against');
|
||||
|
||||
const decoded = jwt.decode(idToken, { complete: true });
|
||||
if (!decoded || !decoded.header) throw new Error('id_token is not a JWT');
|
||||
if (!ALLOWED_ALGS.includes(decoded.header.alg)) {
|
||||
throw new Error(`refusing id_token algorithm ${decoded.header.alg}`);
|
||||
}
|
||||
|
||||
const doc = await discover(issuer);
|
||||
const key = await keyForKid(doc.jwks_uri, decoded.header.kid);
|
||||
|
||||
// jsonwebtoken checks signature, exp, nbf, iss and aud. The algorithm allowlist is passed
|
||||
// explicitly so the header cannot choose how it is verified.
|
||||
const claims = jwt.verify(idToken, key, {
|
||||
algorithms: ALLOWED_ALGS,
|
||||
issuer: doc.issuer,
|
||||
audience: clientId,
|
||||
clockTolerance: 60,
|
||||
});
|
||||
|
||||
if (claims.nonce !== nonce) throw new Error('id_token nonce does not match this login');
|
||||
|
||||
/*
|
||||
* azp names the party the token was issued TO when it differs from the audience. If it is present
|
||||
* it must be us: a token with our client_id merely in a multi-valued `aud`, issued to a different
|
||||
* application, is exactly the confused-deputy case this whole file exists to prevent.
|
||||
*/
|
||||
if (claims.azp && claims.azp !== clientId) {
|
||||
throw new Error('id_token was issued to a different application');
|
||||
}
|
||||
if (!claims.sub) throw new Error('id_token has no subject');
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** PKCE S256. The verifier never leaves us; only its hash goes to the provider. */
|
||||
function createPkce() {
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
return { verifier, challenge, method: 'S256' };
|
||||
}
|
||||
|
||||
const randomToken = () => crypto.randomBytes(32).toString('base64url');
|
||||
|
||||
/**
|
||||
* Exchange the authorization code.
|
||||
*
|
||||
* PKCE means a public client needs no secret, which is what lets a self-hoster configure a provider
|
||||
* without one. A secret is still sent when configured, because some providers (and some admins)
|
||||
* require confidential clients.
|
||||
*/
|
||||
async function exchangeCode({ issuer, clientId, clientSecret, code, redirectUri, verifier }) {
|
||||
const doc = await discover(issuer);
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' };
|
||||
if (clientSecret) {
|
||||
// client_secret_basic is the form every provider accepts; client_secret_post is not universal.
|
||||
headers.Authorization = 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64');
|
||||
}
|
||||
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
||||
let payload;
|
||||
try {
|
||||
assertFetchable(doc.token_endpoint);
|
||||
const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal, redirect: 'manual' });
|
||||
payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
// The provider's own error is far more useful than "exchange failed" — a wrong redirect_uri
|
||||
// or an unregistered client is the overwhelmingly common setup mistake and it says so here.
|
||||
throw new Error(payload.error_description || payload.error || `token endpoint responded ${res.status}`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!payload.id_token) throw new Error('provider returned no id_token — is the openid scope requested?');
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Test seam: drop cached discovery/JWKS so a test can change what a provider claims. */
|
||||
function _resetCaches() {
|
||||
discoveryCache.clear();
|
||||
jwksCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's published keys, straight from the document. Used by the configuration test so an
|
||||
* admin learns at setup time that a provider publishes no signing keys, rather than at first login.
|
||||
*/
|
||||
async function fetchJwks(jwksUri) {
|
||||
return getJson(jwksUri);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
discover,
|
||||
assertFetchable,
|
||||
fetchJwks,
|
||||
verifyIdToken,
|
||||
exchangeCode,
|
||||
createPkce,
|
||||
randomToken,
|
||||
ALLOWED_ALGS,
|
||||
_resetCaches,
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* The CSS needed to rotate a full-screen player container.
|
||||
* The CSS needed to rotate a full-screen player container, and (below) the CSS needed to show that
|
||||
* rotated output back to a human in the dashboard.
|
||||
*
|
||||
* This looks trivial and is not, because rotating a box does NOT move it. The web player set
|
||||
* `width:100vh; height:100vw` and `rotate(90deg)` on a container pinned `inset: 0`, which leaves
|
||||
|
|
@ -63,9 +64,72 @@ function orientationStyle(orientation) {
|
|||
};
|
||||
}
|
||||
|
||||
/** Does this orientation put the panel's long edge vertical? 90 and 270 swap the axes; 0/180 don't. */
|
||||
function swapsAxes(orientation) {
|
||||
const deg = ROTATION_DEG[orientation];
|
||||
return deg === 90 || deg === 270;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSS needed to show a rotated display's OUTPUT — its framebuffer — inside a fixed dashboard
|
||||
* box, as a person standing in front of the panel sees it.
|
||||
*
|
||||
* #238: the dashboard preview of a 90/270 device was sideways while the panel was right, and the
|
||||
* reason is that the dashboard only did half the job. A portrait panel is a landscape framebuffer
|
||||
* that the player rotates content INSIDE (+90), hung on the wall turned the other way (-90); the
|
||||
* two cancel and the viewer sees upright portrait. The dashboard iframed the player into a box it
|
||||
* had already made portrait-shaped, so the player rotated content a second time inside a box that
|
||||
* was pretending to be the finished picture — one rotation applied, the mount's never modelled.
|
||||
* Designers checking their work on a portrait screen saw sideways content and could not tell a
|
||||
* real fault from a preview artefact, so every portrait anomaly became a support question.
|
||||
*
|
||||
* So the frame stands in for the physical mount and rotates by the INVERSE of the player's angle.
|
||||
* Rotating it the SAME way instead is the tempting mistake and the worst kind of wrong: 90+90
|
||||
* lands upside-down, which reads as "nearly right" and gets shipped.
|
||||
*
|
||||
* The dimension swap matters as much as the angle. Composing into a box the shape of the real
|
||||
* FRAMEBUFFER (stage axes swapped) and turning that is not a no-op round trip — it is what makes
|
||||
* the player lay the content out in the same portrait box the panel uses. Feed the player a
|
||||
* portrait-shaped viewport instead and every zone, aspect and object-fit decision is computed for
|
||||
* the wrong canvas.
|
||||
*
|
||||
* @param {string} orientation landscape | portrait | landscape-flipped | portrait-flipped
|
||||
* @param {{width:number,height:number}} box the on-screen stage, in px, AS THE VIEWER SEES IT
|
||||
* @returns {{transform:string,width:string,height:string,top:string,left:string,transformOrigin:string}}
|
||||
* Values to assign directly onto element.style. Empty string means "clear it" — the
|
||||
* landscape state must clear every property the rotated state sets, or a device switched
|
||||
* back to landscape keeps a stale swapped size and looks broken until a reload.
|
||||
*/
|
||||
function previewFrameStyle(orientation, box) {
|
||||
const deg = ROTATION_DEG[orientation];
|
||||
const w = box && box.width, h = box && box.height;
|
||||
|
||||
// Unknown/landscape, or a stage that has not been laid out yet (a hidden tab measures 0x0):
|
||||
// clear back to the base CSS rather than pinning a 0px frame nobody can see.
|
||||
if (!deg || !(w > 0) || !(h > 0)) {
|
||||
return { transform: '', width: '', height: '', top: '', left: '', transformOrigin: '' };
|
||||
}
|
||||
|
||||
const swap = swapsAxes(orientation);
|
||||
return {
|
||||
transform: 'translate(-50%, -50%) rotate(' + ((360 - deg) % 360) + 'deg)',
|
||||
width: (swap ? h : w) + 'px',
|
||||
height: (swap ? w : h) + 'px',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
};
|
||||
}
|
||||
|
||||
/** Stage aspect for a device, as the viewer sees it: '9 / 16' for a portrait-hung 16:9 panel. */
|
||||
function previewAspectRatio(orientation, panelW, panelH) {
|
||||
const w = panelW || 16, h = panelH || 9;
|
||||
return swapsAxes(orientation) ? (h + ' / ' + w) : (w + ' / ' + h);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { orientationStyle, ROTATION_DEG };
|
||||
module.exports = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG };
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.OrientationStyle = { orientationStyle, ROTATION_DEG };
|
||||
window.OrientationStyle = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ const { rollingCounter, bump, read } = require('./rolling-counter');
|
|||
const rateBackoffCtr = rollingCounter();
|
||||
|
||||
// --- minimal semver-ish parse/compare (no dependency) ---
|
||||
const { preCmp } = require('./version-precedence');
|
||||
|
||||
function parseVer(v) {
|
||||
if (typeof v !== 'string') return null;
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
||||
|
|
@ -54,8 +56,10 @@ function cmpParsed(a, b) {
|
|||
if (a.pre === b.pre) return 0;
|
||||
if (a.pre === null) return 1; // release outranks a prerelease of the same core
|
||||
if (b.pre === null) return -1;
|
||||
// lexical prerelease compare — fine for beta1..beta9 (cores decide everything else).
|
||||
return a.pre < b.pre ? -1 : (a.pre > b.pre ? 1 : 0);
|
||||
// Natural prerelease compare: digit runs numerically, so alpha8 < alpha9 < alpha10 < alpha11.
|
||||
// A plain lexical compare (what this used to do) put every build from alpha10 onward BELOW
|
||||
// alpha8, so the check answered client-newer and the fleet could not be moved forward at all.
|
||||
return preCmp(a.pre, b.pre);
|
||||
}
|
||||
function cmp(a, b) { const pa = parseVer(a), pb = parseVer(b); return (!pa || !pb) ? null : cmpParsed(pa, pb); }
|
||||
|
||||
|
|
|
|||
|
|
@ -56,59 +56,168 @@ const CAP_SET = new Set(CAPABILITIES);
|
|||
/*
|
||||
* Baselines for displays that declare nothing.
|
||||
*
|
||||
* Only things that have always worked on that platform. Anything conditional — screenshots that
|
||||
* need accessibility, kiosk that needs device owner, native sync that needs one L2 network — is
|
||||
* omitted, so a legacy display shows those controls only once it declares them. Better a control
|
||||
* that appears late than one that lies today.
|
||||
* THE RULE, and it is the only one that keeps this table honest: a baseline entry describes what
|
||||
* the LAST RELEASED player for that platform does, unconditionally, with no privilege it might not
|
||||
* have been granted. Not what HEAD does — HEAD declares for itself. Not what the platform could do
|
||||
* — a capability nobody shipped is a button nobody can press.
|
||||
*
|
||||
* Every entry below was checked against `git show v1.9.28:<player source>`, the last release before
|
||||
* capability declaration existed at all, because v1.9.29 is the first build in which any player
|
||||
* declares anything. Every display that falls back to a baseline is therefore running v1.9.28 or
|
||||
* older by construction, and that is the build the justifications cite.
|
||||
*/
|
||||
const BASELINE = {
|
||||
android: [
|
||||
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
||||
'playback.zones', 'playback.transitions', 'playback.pip',
|
||||
// set_volume and set_brightness are #160 Track-A, released in v1.9.10 — long before anything
|
||||
// still in the field. Both are Tier 0: MainActivity applies them with no owner, no admin and
|
||||
// no WRITE_SETTINGS, so they are unconditional on any build a fielded panel could be running.
|
||||
'audio.mute', 'audio.volume',
|
||||
'display.rotation', 'display.power', 'display.brightness',
|
||||
'remote.screenshot', 'remote.stream', 'remote.input',
|
||||
'system.reboot', 'system.restart_player', 'system.self_update',
|
||||
// Capture without accessibility falls back to ScreenshotCapture.captureView, which is a real
|
||||
// frame of the player's own view — i.e. of the content. Narrower than the full-screen path,
|
||||
// but the operator gets a picture, not a dead button.
|
||||
'remote.screenshot', 'remote.stream',
|
||||
'remote.input',
|
||||
'system.restart_player', 'system.self_update',
|
||||
'sync.clock', 'offline.cache',
|
||||
// display.power is KEPT for the un-updated Android fleet, deliberately, with the trade-off
|
||||
// recorded here because it is genuinely two-sided.
|
||||
//
|
||||
// v1.9.28 MainActivity answers screen_on with
|
||||
// Log.w("screen_on: no privileged wake path on a non-rooted panel — no-op")
|
||||
// so the ON half is dead on every fielded Android panel, while screen_off does work (device
|
||||
// owner / device-admin FORCE_LOCK, else the accessibility lock). One capability renders BOTH
|
||||
// dashboard buttons, so this baseline cannot offer the working half without the dead one.
|
||||
//
|
||||
// Withholding it takes away blank-at-night, which is the half signage actually schedules, from
|
||||
// every panel that has not updated. Keeping it means an operator can sleep a screen and not
|
||||
// wake it from the dashboard — mitigated by the fact that a schedule, a restart, or anyone
|
||||
// standing at the panel will wake it, while nothing else can blank it.
|
||||
//
|
||||
// A panel that HAS updated declares for itself, and PlayerCapabilities.kt gates its own claim
|
||||
// on both halves — so this governs the un-updated fleet only. If the dead ON button turns out
|
||||
// to be the louder complaint, split it into display.power_off / display.power_on rather than
|
||||
// dropping the pair.
|
||||
//
|
||||
// NOT system.reboot. STPolicy.reboot() requires device owner; off-owner v1.9.28 falls back to
|
||||
// the accessibility power DIALOG, which needs a human standing at the screen — and on the
|
||||
// accessibility-enabled panels that are common in this fleet it paints that dialog OVER the
|
||||
// signage. Device-owner provisioning is not released (#161/PR #168 is still open), so the set
|
||||
// of panels that are both device owner AND pre-1.9.29 is effectively empty.
|
||||
// ⚠️ Consequence, deliberately accepted: services/scheduler.js gates the nightly scheduled
|
||||
// reboot on this capability, so scheduled reboots now no-op for undeclared Android panels
|
||||
// instead of logging "scheduled reboot fired" for a panel that never rebooted. That log line
|
||||
// is the reason the gate is there; the honest answer is to skip, not to claim.
|
||||
//
|
||||
// NOT system.shell / system.kiosk / system.time / system.install_apk / system.brightness /
|
||||
// system.screen_timeout: every one is device-owner or WRITE_SETTINGS conditional.
|
||||
],
|
||||
tizen: [
|
||||
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
||||
'playback.zones', 'playback.transitions', 'playback.pip',
|
||||
// audio.mute only. A FIELDED Tizen panel has no set_volume handler at all — the command falls
|
||||
// through to "unknown command", so the dashboard slider does nothing. Updated panels declare
|
||||
// audio.volume for themselves once they ship a handler; the baseline describes what an
|
||||
// un-updated one can actually do, which is the whole reason it exists.
|
||||
// audio.mute only. `git show v1.9.28:tizen/js/app.js` has NO set_volume handler — the command
|
||||
// falls through STDeviceControl.run to "unknown command", so the dashboard slider does nothing
|
||||
// on every fielded panel. (HEAD ships applyVolume, but see the note on BASELINE.web: it reads
|
||||
// payload.value while the dashboard sends payload.level, so even HEAD's slider is dead. The
|
||||
// baseline stays out until a released .wgt honours the payload the product actually sends.)
|
||||
'audio.mute',
|
||||
'display.rotation',
|
||||
// Both really are implemented in the shipped player (captureAndSend / startStreaming), so
|
||||
// omitting them would have hidden working controls on every legacy Tizen panel.
|
||||
'remote.screenshot', 'remote.stream',
|
||||
'remote.input',
|
||||
// ADDED after audit. v1.9.28 app.js implements BOTH halves with no partner signing and no
|
||||
// panel API: screen_off -> showScreenOff() paints the blanking overlay, screen_on ->
|
||||
// clearScreenOff() + keepAwake(). Unlike Android above, neither half is privilege-gated, so
|
||||
// the pair is honest. The panel backlight stays lit — the log line says which mechanism ran —
|
||||
// but the screen genuinely goes dark, and HEAD's capabilities.js declares it for that reason.
|
||||
'display.power',
|
||||
'system.restart_player',
|
||||
'sync.clock',
|
||||
// NOT offline.cache: Tizen caches only the playlist JSON (st_payload_cache in localStorage).
|
||||
// There is no service worker and no media cache, so the bytes still come from the network and
|
||||
// content does NOT survive an outage. My first baseline claimed it — caught by the platform
|
||||
// audit, and exactly the kind of optimistic claim this model exists to stop.
|
||||
// NOT offline.cache: v1.9.28 has no tizen/js/media-cache.js at all (the file is new at HEAD).
|
||||
// The fielded player caches only the playlist JSON (st_payload_cache in localStorage), so an
|
||||
// outage leaves the panel knowing exactly what it cannot show. My first baseline claimed it —
|
||||
// caught by the platform audit, and exactly the kind of optimistic claim this model exists to
|
||||
// stop.
|
||||
],
|
||||
/*
|
||||
* A BrightSign that declares nothing is a BrightSign we cannot prove has a host bridge, and that
|
||||
* is the whole story of this baseline.
|
||||
*
|
||||
* The JS half of the bridge is served BY US (server.js routes /player/st-bridge.js at
|
||||
* brightsign/st-bridge.js), so it is always current — but it is only half. `port` exists only
|
||||
* inside an roHtmlWidget created with nodejs_enabled:true, which is the on-device BrightScript's
|
||||
* decision, and `git ls-tree v1.9.28 brightsign/` shows no st-bridge.js at all: no released
|
||||
* package ever shipped the two halves as a pair. The one real BrightSign we have runs BSN
|
||||
* Supervisor's widget rather than our autorun.brs, and BS.hasHost() is false on it.
|
||||
*
|
||||
* A unit that DOES have a bridge declares for itself and never reads this list — the page
|
||||
* computes hasHost() at registration. So this baseline only ever answers for a row that has not
|
||||
* re-registered, and the right answer for a display we know nothing about is the floor:
|
||||
* everything below is "the web player with no bridge", and nothing above that.
|
||||
*/
|
||||
brightsign: [
|
||||
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
||||
'playback.zones', 'playback.transitions', 'playback.pip',
|
||||
'audio.mute', 'audio.volume',
|
||||
'display.rotation', 'display.power',
|
||||
'audio.mute',
|
||||
// CSS transform. Graphics rotate; with hwz the video sits on a hardware plane that ignores it,
|
||||
// so this is partial — but nothing routes a COMMAND to display.rotation and no control is
|
||||
// gated on it, so the entry describes content rendering rather than offering a button.
|
||||
'display.rotation',
|
||||
'remote.input',
|
||||
'system.reboot', 'system.restart_player',
|
||||
'sync.clock', 'offline.cache',
|
||||
// RESTORED in 1.9.31 with BASELINE.web, and for the same reason plus one of its own: a
|
||||
// BrightSign runs the web player we serve, so it gets the fixed handler the moment the server
|
||||
// does. The unit-specific question is whether the media element is even reachable on a player
|
||||
// that puts video on a hardware plane — and that question is already settled by `audio.mute`
|
||||
// above, which this baseline has always claimed: set_volume reaches setMediaVolume() and
|
||||
// device:mute-changed reaches `currentVideoEl.muted`, the same element by the same path. If hwz
|
||||
// silently swallowed one it would swallow both, so volume is exactly as honest as mute here.
|
||||
'audio.volume',
|
||||
'sync.clock',
|
||||
// NOT offline.cache. This is the documented case, not a hypothetical: the XT245 on alpha has
|
||||
// navigator.serviceWorker, passes every presence check, and then never fetches sw.js because
|
||||
// its widget refuses the registration. It advertised offline caching to the fleet and could
|
||||
// not cache one byte. A widget with no storage_path has no persistent storage at all, and the
|
||||
// baseline cannot know which kind of widget it is talking to.
|
||||
//
|
||||
// NOT system.restart_player. `refresh` reaches restartPlayer(), which without a host does
|
||||
// location.reload() — and a page-initiated reload does not reliably bring an roHtmlWidget
|
||||
// back. That is what darkened a customer's panel on 2026-07-28. st-bridge.js withholds this
|
||||
// for the same reason; a baseline that hands it to every undeclared unit undoes that.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// NOT remote.screenshot / remote.stream: a canvas capture on a hwz player cannot read the video
|
||||
// plane, so it returns a frame with a hole where the content is. (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
|
||||
// capture anything outside its own document.
|
||||
web: [
|
||||
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
|
||||
'playback.zones', 'playback.transitions', 'playback.pip',
|
||||
'audio.mute', 'audio.volume',
|
||||
'audio.mute',
|
||||
'display.rotation',
|
||||
'remote.screenshot', 'remote.stream', 'remote.input',
|
||||
'system.restart_player',
|
||||
// RESTORED in 1.9.31, having been removed by the audit that found the slider dead. Both of the
|
||||
// reasons it was removed have expired, and the second one was reasoning from the wrong artifact:
|
||||
// 1. It read `data.payload?.value ?? data.value` while the dashboard sends `{ level: 0..1 }`,
|
||||
// so the number was undefined and the handler declined. Fixed in 1.9.31 — index.html now
|
||||
// takes the fraction as canonical (volumeLevelFromCommand) and set_volume reaches
|
||||
// setMediaVolume().
|
||||
// 2. The removal cited `git show v1.9.28:server/player/index.html` having no handler at all.
|
||||
// But this player is SERVED BY THE SERVER: a browser panel loads it from whatever build is
|
||||
// running, not from the release its row was created under. There is no such thing as a
|
||||
// browser panel stuck on the v1.9.28 player once the server moves — which is the whole
|
||||
// difference between this baseline and the Android/Tizen ones below, where an un-updated
|
||||
// panel really is running an old artifact.
|
||||
// So the moment the server ships the fix, an undeclared web display can be driven, and holding
|
||||
// the entry back would hide a control that works. Released and live on prod 2026-08-06.
|
||||
'audio.volume',
|
||||
'sync.clock', 'offline.cache',
|
||||
],
|
||||
};
|
||||
|
|
@ -120,8 +229,14 @@ const BASELINE = {
|
|||
function platformFamily(device) {
|
||||
const platform = String((device && device.platform) || '').toLowerCase();
|
||||
const android = String((device && device.android_version) || '');
|
||||
const clientType = (device && device.client_type) || '';
|
||||
if (platform.includes('brightsign')) return 'brightsign';
|
||||
if (platform.includes('tizen')) return 'tizen';
|
||||
// Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt' (see
|
||||
// tizen/js/app.js). `platform` is the primary key, but it lives in a column that a register from
|
||||
// a client not sending it used to overwrite — and misreading a Tizen panel as a browser tab
|
||||
// hands it a volume slider with no handler behind it. Two signals, one conclusion.
|
||||
if (clientType === 'wgt') return 'tizen';
|
||||
// client_type 'apk' is the Android player; android_version that is NOT the web player's
|
||||
// "Web/..." shape is the older signal for the same thing.
|
||||
if ((device && device.client_type === 'apk') || (android && !android.startsWith('Web/'))) return 'android';
|
||||
|
|
@ -182,6 +297,11 @@ function parseDeclared(raw) {
|
|||
*
|
||||
* A command mapped to null needs no capability: it is a diagnostic every player understands, and
|
||||
* refusing it would remove the tool you use to work out why a panel is misbehaving.
|
||||
*
|
||||
* A command may map to a LIST, meaning any one of them is enough. That is not a convenience: it is
|
||||
* how a capability name that no shipped player declares stays in the vocabulary without taking its
|
||||
* commands down with it. The first name in the list is the canonical one and is what a refusal
|
||||
* reports, so the operator is told what the panel is missing in the vocabulary they see elsewhere.
|
||||
*/
|
||||
const COMMAND_CAPABILITY = {
|
||||
// lifecycle
|
||||
|
|
@ -192,6 +312,9 @@ const COMMAND_CAPABILITY = {
|
|||
launch: 'system.restart_player',
|
||||
refresh: 'system.restart_player',
|
||||
update: 'system.self_update',
|
||||
// Clearing the staged-APK cache is part of the same self-update surface: a player that can
|
||||
// update itself is a player that can hold a bad download and needs a way to drop it.
|
||||
clear_update_cache: 'system.self_update',
|
||||
|
||||
// display
|
||||
screen_on: 'display.power',
|
||||
|
|
@ -208,18 +331,49 @@ const COMMAND_CAPABILITY = {
|
|||
// device-owner surface (#161 Tier-2)
|
||||
kiosk_lock: 'system.kiosk',
|
||||
kiosk_unlock: 'system.kiosk',
|
||||
lock_now: 'system.device_owner',
|
||||
power_menu: 'system.device_owner',
|
||||
status_bar: 'system.device_owner',
|
||||
block_uninstall: 'system.device_owner',
|
||||
unblock_uninstall: 'system.device_owner',
|
||||
/*
|
||||
* ⚠️ These five were UNREACHABLE for the entire fleet until this audit, and nothing failed
|
||||
* loudly enough to notice.
|
||||
*
|
||||
* 'system.device_owner' is declared by NO player. It is not in PlayerCapabilities.kt, not in
|
||||
* tizen/js/capabilities.js, not in the web player's declaredCapabilities(), not in st-bridge.js,
|
||||
* and not in any baseline. So `supports()` returned false for every device on every platform,
|
||||
* and every one of these commands was refused — including on the device-owner panels the whole
|
||||
* #161 Tier-2 surface was built for. The dashboard still rendered the buttons, because
|
||||
* device-detail.js gates that block on `device.tier === 2 ||` as well, so an operator on a real
|
||||
* owner panel pressed "Lock now" and got a silent server-side refusal.
|
||||
*
|
||||
* Until a player declares 'system.device_owner' for itself, 'system.kiosk' stands in, and it is
|
||||
* an exact stand-in rather than a loose one: PlayerCapabilities.kt declares system.kiosk under
|
||||
* `if (isOwner)` and nothing else, which is precisely the condition under which STPolicy's
|
||||
* owned() actions — setStatusBarDisabled, setUninstallBlocked, lockNow, reboot — do anything.
|
||||
* No non-Android player declares system.kiosk; Tizen and BrightSign both refuse it explicitly
|
||||
* and in writing, so this cannot leak the commands onto a platform that would swallow them.
|
||||
*
|
||||
* The canonical name stays first so a refusal still says 'system.device_owner'.
|
||||
*/
|
||||
lock_now: ['system.device_owner', 'system.kiosk'],
|
||||
power_menu: ['system.device_owner', 'system.kiosk'],
|
||||
status_bar: ['system.device_owner', 'system.kiosk'],
|
||||
block_uninstall: ['system.device_owner', 'system.kiosk'],
|
||||
unblock_uninstall: ['system.device_owner', 'system.kiosk'],
|
||||
set_time: 'system.time',
|
||||
set_timezone: 'system.time',
|
||||
shell: 'system.shell',
|
||||
install_apk: 'system.install_apk',
|
||||
|
||||
// remote view
|
||||
enable_system_capture: 'remote.screenshot',
|
||||
/*
|
||||
* Remote view. Ungated, and the reason is a circle: enable_system_capture asks Android to raise
|
||||
* the MediaProjection consent dialog, which is how a panel GAINS full-screen capture. Gating it
|
||||
* on 'remote.screenshot' meant the only panel that needs it — one with neither accessibility nor
|
||||
* a projection grant, which therefore declares no remote.screenshot — was the one panel that
|
||||
* could not be sent it. A bootstrap cannot require the thing it bootstraps.
|
||||
*
|
||||
* ⚠️ The dashboard still has the other half of this bug: device-detail.js renders the "enable
|
||||
* system view" button behind `can('remote.screenshot')`. Fixing that is a frontend change and is
|
||||
* written up in docs/player-parity.md; ungating the command is the half that lives here.
|
||||
*/
|
||||
enable_system_capture: null,
|
||||
|
||||
// Diagnostics: deliberately unrestricted. set_debug turns on the log stream you need precisely
|
||||
// when a panel is behaving in a way its capability declaration did not predict.
|
||||
|
|
@ -227,13 +381,29 @@ const COMMAND_CAPABILITY = {
|
|||
};
|
||||
|
||||
/**
|
||||
* The capability a command requires, or null when it needs none.
|
||||
* Unknown commands also return null — this map gates, it does not authorise: the allow-list of
|
||||
* Every capability that would satisfy `type`, as an array. Empty means the command is ungated.
|
||||
* Unknown commands are ungated too — this map gates, it does not authorise: the allow-list of
|
||||
* valid command names lives with the routes, and duplicating it here would mean a new command
|
||||
* silently stops working until someone remembers to add it in two places.
|
||||
*
|
||||
* @param {string} type
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function capabilitiesForCommand(type) {
|
||||
if (!Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type)) return [];
|
||||
const value = COMMAND_CAPABILITY[type];
|
||||
if (value === null || value === undefined) return [];
|
||||
return Array.isArray(value) ? value.slice() : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* The CANONICAL capability a command requires, or null when it needs none.
|
||||
* Kept returning a single string because that is what a refusal reports and what the dashboard
|
||||
* puts in front of an operator: "needs system.device_owner" is an answer, an array is a puzzle.
|
||||
*/
|
||||
function capabilityForCommand(type) {
|
||||
return Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type) ? COMMAND_CAPABILITY[type] : null;
|
||||
const list = capabilitiesForCommand(type);
|
||||
return list.length ? list[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -241,13 +411,13 @@ function capabilityForCommand(type) {
|
|||
* @returns {{ok: true} | {ok: false, capability: string}}
|
||||
*/
|
||||
function commandAllowed(device, type) {
|
||||
const cap = capabilityForCommand(type);
|
||||
if (!cap) return { ok: true };
|
||||
if (supports(device, cap)) return { ok: true };
|
||||
return { ok: false, capability: cap };
|
||||
const needed = capabilitiesForCommand(type);
|
||||
if (!needed.length) return { ok: true };
|
||||
if (needed.some((cap) => supports(device, cap))) return { ok: true };
|
||||
return { ok: false, capability: needed[0] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared,
|
||||
COMMAND_CAPABILITY, capabilityForCommand, commandAllowed,
|
||||
COMMAND_CAPABILITY, capabilityForCommand, capabilitiesForCommand, commandAllowed,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@
|
|||
//
|
||||
// Dependency-free UMD: Node (require) + browser/Tizen (window.PlayerMediaHealth).
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.PlayerMediaHealth = factory();
|
||||
// BOTH, not either/or — see schedule-eval.js. Node integration in a BrightSign widget made the
|
||||
// browser branch unreachable, so the player ran without its media-health decision there.
|
||||
var api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.PlayerMediaHealth = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
|
|
|
|||
190
server/lib/preflight-deps.js
Normal file
190
server/lib/preflight-deps.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Make sure the dependencies this build needs are actually installed and loadable — BEFORE anything
|
||||
* requires them.
|
||||
*
|
||||
* The normal upgrade path (scripts/upgrade.sh) runs `npm ci --omit=dev`, so this is not for the
|
||||
* happy case. It is for the three ways a running box ends up with the wrong node_modules:
|
||||
*
|
||||
* ROLLBACK checking out an older tag to back out a bad release restores that tag's
|
||||
* package.json but not its packages, so the server dies on a MODULE_NOT_FOUND for
|
||||
* something the newer build had removed. That is a bad moment to be reading a
|
||||
* stack trace: you are already rolling back because something else broke.
|
||||
* NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes
|
||||
* every boot fail with NODE_MODULE_VERSION mismatch, which reads like database
|
||||
* corruption and is not.
|
||||
*
|
||||
* ⚠️ better-sqlite3 is pinned to EXACTLY 12.9.0, not a caret range, and the reason
|
||||
* is invisible from package.json: 12.10.0 DROPPED the prebuilt binary for Node 20
|
||||
* (ABI 115) while still advertising `"node": "20.x || ..."` in engines. So a caret
|
||||
* resolves to 12.11.x, finds no prebuild on Node 20, and silently falls through to
|
||||
* `node-gyp rebuild` — a from-source compile during install, and during the repair
|
||||
* below. That matters here: this file rebuilds synchronously BEFORE the server
|
||||
* listens, and prod's systemd unit has TimeoutStartSec=90 with Restart=always, so a
|
||||
* slow or failing compile is a boot loop rather than a self-heal. 12.9.0 is the last
|
||||
* version shipping prebuilds for BOTH Node 20 (115) and Node 22 (127), which is what
|
||||
* lets the runtime move without the module having to compile at all.
|
||||
* Re-check the release assets before widening the pin.
|
||||
* HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install.
|
||||
*
|
||||
* All three present as a server that will not start, with an error that names a file rather than
|
||||
* the action needed. Detecting and repairing is a few seconds; diagnosing is an outage.
|
||||
*
|
||||
* ⚠️ Deliberately dependency-free — only Node builtins. Anything it required could be the very
|
||||
* thing that is missing.
|
||||
*
|
||||
* Set ST_SKIP_DEP_PREFLIGHT=1 to turn it off (air-gapped hosts, or an operator who manages
|
||||
* node_modules themselves and does not want a boot reaching for the network).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SERVER_DIR = path.join(__dirname, '..');
|
||||
const NODE_MODULES = path.join(SERVER_DIR, 'node_modules');
|
||||
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; // a cold install on a Pi is genuinely slow
|
||||
|
||||
/** Which declared dependencies are not on disk. */
|
||||
function missingDeps() {
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(fs.readFileSync(path.join(SERVER_DIR, 'package.json'), 'utf8'));
|
||||
} catch {
|
||||
return []; // no package.json is not our problem to diagnose
|
||||
}
|
||||
const declared = Object.keys(pkg.dependencies || {});
|
||||
return declared.filter((name) => {
|
||||
// A scoped or nested name is still one directory below node_modules.
|
||||
try { return !fs.existsSync(path.join(NODE_MODULES, name, 'package.json')); } catch { return true; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the native module loadable by THIS Node?
|
||||
*
|
||||
* Checked by actually loading it, because the failure is an ABI mismatch that no version string
|
||||
* comparison catches reliably — a rebuild against the same major can still differ.
|
||||
*/
|
||||
function nativeModuleBroken() {
|
||||
try {
|
||||
/*
|
||||
* ⚠️ CONSTRUCT one, do not merely require it.
|
||||
*
|
||||
* better-sqlite3's entry point is plain JavaScript and loads the compiled `.node` binding
|
||||
* lazily, so `require()` alone SUCCEEDS under a Node whose ABI the binary was not built for —
|
||||
* the first version of this check did exactly that and reported a broken install as healthy,
|
||||
* verified against a real Node 18 / Node 20 mismatch. Opening an in-memory database is what
|
||||
* actually pulls the binding in, and it touches no file.
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
new Database(':memory:').close();
|
||||
return null;
|
||||
} catch (e) {
|
||||
const msg = String((e && e.message) || '');
|
||||
if (/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|was compiled against a different/i.test(msg)) return msg;
|
||||
if (/Cannot find module/i.test(msg)) return msg;
|
||||
// Anything else is a real error in the module, not an installation problem — let it surface
|
||||
// later with its own stack rather than being masked by an npm run.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function run(args, label) {
|
||||
console.log(`[preflight] ${label}: npm ${args.join(' ')}`);
|
||||
execFileSync('npm', args, { cwd: SERVER_DIR, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
function fail(reason, hint) {
|
||||
console.error(`[preflight] ${reason}`);
|
||||
console.error(`[preflight] ${hint}`);
|
||||
console.error('[preflight] Set ST_SKIP_DEP_PREFLIGHT=1 to boot without this check.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function preflight() {
|
||||
// Same spellings as every other boolean the server accepts, so an operator who writes `true`
|
||||
// does not silently get a boot that reaches for the registry anyway.
|
||||
if (['1', 'true', 'yes'].includes(String(process.env.ST_SKIP_DEP_PREFLIGHT || '').toLowerCase())) return;
|
||||
|
||||
const missing = missingDeps();
|
||||
const nodeModulesAbsent = !fs.existsSync(NODE_MODULES);
|
||||
|
||||
if (missing.length || nodeModulesAbsent) {
|
||||
const what = nodeModulesAbsent
|
||||
? 'node_modules is missing'
|
||||
: `${missing.length} dependency/dependencies missing: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`;
|
||||
console.warn(`[preflight] ${what} — installing before start.`);
|
||||
try {
|
||||
/*
|
||||
* `npm ci` when there is a lockfile and nothing installed: it is reproducible and it is what
|
||||
* upgrade.sh uses. Otherwise `npm install`, because `ci` DELETES node_modules first and would
|
||||
* throw away a working tree to fix one missing package.
|
||||
*/
|
||||
const hasLock = fs.existsSync(path.join(SERVER_DIR, 'package-lock.json'));
|
||||
if (hasLock && nodeModulesAbsent) {
|
||||
/*
|
||||
* Nothing installed, so `ci` has nothing to destroy and gives a reproducible tree.
|
||||
*
|
||||
* `--omit=dev` ONLY when this is plainly a production boot. Applying it unconditionally
|
||||
* meant a cold start on a developer machine installed 307 packages and left `npm test`
|
||||
* broken — js-yaml, puppeteer-core and socket.io-client absent — which is the same class of
|
||||
* surprise as the prune this file already warns about, arriving through the other branch of
|
||||
* the same `if`.
|
||||
*/
|
||||
const prod = process.env.NODE_ENV === 'production';
|
||||
run(prod ? ['ci', '--omit=dev', '--no-audit', '--no-fund'] : ['ci', '--no-audit', '--no-fund'], 'installing');
|
||||
} else {
|
||||
/*
|
||||
* ⚠️ Install ONLY what is missing, by name, and never `--omit=dev` on a populated tree.
|
||||
*
|
||||
* `npm install --omit=dev` reconciles the whole tree, which PRUNES devDependencies — so
|
||||
* merely starting the server deleted socket.io-client, puppeteer-core and js-yaml, and broke
|
||||
* `npm test`. A review watched it happen. A boot-time repair that quietly removes packages
|
||||
* is worse than the failure it fixes, so this touches nothing it was not asked to.
|
||||
*
|
||||
* `--no-save` because a server starting up has no business editing package.json.
|
||||
*/
|
||||
run(['install', '--no-save', '--no-audit', '--no-fund', ...missing], 'installing missing packages');
|
||||
}
|
||||
} catch (e) {
|
||||
/*
|
||||
* An install can fail because ANOTHER server started at the same moment and won the race —
|
||||
* observed as `ENOTEMPTY … rename node_modules/fs-extra`. The tree is complete by the time we
|
||||
* see the error, so exiting here killed a process that had nothing wrong with it. Re-check
|
||||
* before giving up; only a genuinely incomplete tree is fatal.
|
||||
*/
|
||||
const afterFailure = missingDeps();
|
||||
if (afterFailure.length) {
|
||||
fail(`could not install dependencies: ${e && e.message}`,
|
||||
'Run `npm ci --omit=dev` in the server directory, or check network access to the npm registry.');
|
||||
}
|
||||
console.warn(`[preflight] install reported an error but the tree is complete (${e && e.message}) — continuing.`);
|
||||
}
|
||||
const still = missingDeps();
|
||||
if (still.length) {
|
||||
fail(`still missing after install: ${still.join(', ')}`, 'Check the npm output above.');
|
||||
}
|
||||
console.log('[preflight] dependencies installed.');
|
||||
}
|
||||
|
||||
const nativeProblem = nativeModuleBroken();
|
||||
if (nativeProblem) {
|
||||
console.warn(`[preflight] better-sqlite3 will not load under Node ${process.version} — rebuilding.`);
|
||||
console.warn(`[preflight] ${nativeProblem.split('\n')[0]}`);
|
||||
try {
|
||||
run(['rebuild', 'better-sqlite3'], 'rebuilding native module');
|
||||
} catch (e) {
|
||||
fail(`could not rebuild better-sqlite3: ${e && e.message}`,
|
||||
`Run \`npm rebuild better-sqlite3\` in the server directory. This usually means Node changed version (now ${process.version}) and the module needs recompiling; a build toolchain (python3, make, g++) must be present.`);
|
||||
}
|
||||
if (nativeModuleBroken()) {
|
||||
fail('better-sqlite3 still will not load after a rebuild.',
|
||||
'Delete server/node_modules and run `npm ci --omit=dev`.');
|
||||
}
|
||||
console.log('[preflight] native module rebuilt.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { preflight, missingDeps, nativeModuleBroken };
|
||||
57
server/lib/public-email-domains.js
Normal file
57
server/lib/public-email-domains.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Email domains nobody may claim for organization SSO.
|
||||
*
|
||||
* Per-org SSO routes everyone at a domain to that organization's identity provider. Applied to a
|
||||
* company domain that is the point. Applied to a CONSUMER domain it is an attack: one tenant claims
|
||||
* `gmail.com`, and from then on every Gmail user who types their address into this product's login
|
||||
* page is offered a "sign in with your organization" button that sends them to infrastructure the
|
||||
* tenant controls — phishing launched from the vendor's own trusted login screen. First-claim-wins
|
||||
* also lets one cheap account deny a public domain to everyone else, permanently.
|
||||
*
|
||||
* ⚠️ This is a floor, not a ceiling. It stops the mass-abuse case; it does NOT stop a tenant
|
||||
* claiming a domain that belongs to some specific other company. Only proof of control — a DNS TXT
|
||||
* record, or a challenge to postmaster@ — settles that, and until it exists a claimed domain means
|
||||
* "nobody else had claimed it", not "they own it".
|
||||
*
|
||||
* Kept as data, in one file, because it is a list that will need adding to and that is the cheapest
|
||||
* possible edit. Matching is exact on the registrable domain, so `mail.google.com` is not blocked by
|
||||
* `gmail.com` — subdomains of consumer providers are not a realistic sign-in domain anyway.
|
||||
*/
|
||||
|
||||
const PUBLIC_EMAIL_DOMAINS = new Set([
|
||||
// Google
|
||||
'gmail.com', 'googlemail.com',
|
||||
// Microsoft
|
||||
'outlook.com', 'outlook.co.uk', 'hotmail.com', 'hotmail.co.uk', 'hotmail.fr', 'hotmail.it',
|
||||
'live.com', 'live.co.uk', 'msn.com', 'passport.com',
|
||||
// Yahoo and friends
|
||||
'yahoo.com', 'yahoo.co.uk', 'yahoo.co.jp', 'yahoo.fr', 'yahoo.de', 'yahoo.ca', 'yahoo.com.au',
|
||||
'ymail.com', 'rocketmail.com', 'aol.com', 'aim.com',
|
||||
// Apple
|
||||
'icloud.com', 'me.com', 'mac.com',
|
||||
// Privacy-focused
|
||||
'proton.me', 'protonmail.com', 'pm.me', 'tutanota.com', 'tutanota.de', 'tuta.io', 'tuta.com',
|
||||
'duck.com', 'hey.com', 'fastmail.com', 'fastmail.fm',
|
||||
// Other large consumer providers
|
||||
'gmx.com', 'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'web.de', 'mail.com', 'email.com',
|
||||
'zoho.com', 'zohomail.com', 'yandex.com', 'yandex.ru', 'ya.ru', 'mail.ru', 'bk.ru', 'inbox.ru',
|
||||
'list.ru', 'rambler.ru',
|
||||
'qq.com', 'foxmail.com', '163.com', '126.com', 'sina.com', 'sina.cn', 'naver.com', 'daum.net',
|
||||
'hanmail.net', 'rediffmail.com',
|
||||
// ISP-style mailboxes, where the domain belongs to the ISP and not to any customer
|
||||
'comcast.net', 'verizon.net', 'att.net', 'sbcglobal.net', 'bellsouth.net', 'cox.net',
|
||||
'charter.net', 'earthlink.net', 'juno.com', 'optonline.net', 'roadrunner.com',
|
||||
'btinternet.com', 'sky.com', 'virginmedia.com', 'talktalk.net', 'orange.fr', 'wanadoo.fr',
|
||||
'free.fr', 'laposte.net', 'libero.it', 'virgilio.it', 'tiscali.it', 'terra.com.br', 'uol.com.br',
|
||||
'bol.com.br', 'telus.net', 'shaw.ca', 'rogers.com', 'sympatico.ca', 'bigpond.com', 'optusnet.com.au',
|
||||
't-online.de', 'freenet.de', 'arcor.de',
|
||||
]);
|
||||
|
||||
/** True when this domain is a consumer mailbox provider rather than an organization's own domain. */
|
||||
function isPublicEmailDomain(domain) {
|
||||
return PUBLIC_EMAIL_DOMAINS.has(String(domain || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
module.exports = { PUBLIC_EMAIL_DOMAINS, isPublicEmailDomain };
|
||||
|
|
@ -22,8 +22,13 @@
|
|||
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
|
||||
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.ScheduleEval = factory();
|
||||
// BOTH, not either/or: a BrightSign widget runs with Node integration, so `module` exists in
|
||||
// page scope and an `else` left root.ScheduleEval undefined there. The player falls back to
|
||||
// "always active" when it is missing — i.e. per-item DAYPARTING silently stopped applying on
|
||||
// that platform, and scheduled content played outside its window with nothing in any log.
|
||||
var api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.ScheduleEval = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
|
|
|
|||
200
server/lib/telemetry.js
Normal file
200
server/lib/telemetry.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Opt-in install statistics.
|
||||
*
|
||||
* WHY THIS EXISTS: there is no way to answer "how many screens run ScreenTinker?" — the product is
|
||||
* self-hostable by design, so most installs are invisible to us on purpose. This asks, once, and
|
||||
* only reports if the operator says yes.
|
||||
*
|
||||
* WHAT IS SENT — the whole payload, three fields:
|
||||
*
|
||||
* { instance_id, version, screen_count }
|
||||
*
|
||||
* and nothing else. No hostnames, no addresses, no organization or user names, no device names,
|
||||
* no content or filenames, no user counts. The list is short on purpose: every field added costs
|
||||
* participation, and participation is the only thing that makes the resulting number worth
|
||||
* quoting. Anyone can verify it — the payload is built in `payload()` below, in one place, and
|
||||
* `getLastReport()` shows an operator the exact bytes last sent.
|
||||
*
|
||||
* `instance_id` is a random UUID generated on first use and kept in app_settings. It carries no
|
||||
* information about the install; its only job is to let two reports from the same server be
|
||||
* recognised as the same server, so a count is a count rather than a sum of duplicates. That does
|
||||
* make a report PSEUDONYMOUS rather than anonymous, and the wording shown to operators says so.
|
||||
*
|
||||
* ⚠️ Restoring a backup or cloning a VM carries the id with it, so two installs report as one.
|
||||
* Deliberate: under-counting is the honest failure here, and the alternative (re-identifying on
|
||||
* some hardware signal) means collecting exactly the kind of thing this file promises not to.
|
||||
*
|
||||
* ⚠️ Opt-in populations are self-selected, so the total is a FLOOR — "at least N screens" — never
|
||||
* a basis for extrapolating a fleet size.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const appSettings = require('./app-settings');
|
||||
|
||||
const KEY_ID = 'telemetry_instance_id';
|
||||
const KEY_ENABLED = 'telemetry_enabled'; // unset = never asked
|
||||
const KEY_LAST = 'telemetry_last_report'; // last SUCCESSFUL send
|
||||
const KEY_LAST_ERROR = 'telemetry_last_error';// last FAILED attempt — see getLastError
|
||||
|
||||
/*
|
||||
* Where reports go. TWO independent destinations, deliberately:
|
||||
*
|
||||
* SCREENTINKER_ENDPOINT hard-wired, and reached only when the operator has switched sharing on.
|
||||
* Not overridable — an "override" that silently redirected the shared
|
||||
* report would make the opt-in mean something different from what it says.
|
||||
*
|
||||
* TELEMETRY_EXTRA_ENDPOINT an operator's OWN collector, for their own fleet numbers. Additional,
|
||||
* never a replacement, and named so it cannot be mistaken for one. It is
|
||||
* sent independently of the sharing toggle: it is their server posting to
|
||||
* their host, so our opt-in has no business gating it. An operator who
|
||||
* wants internal statistics and nothing leaving for us sets this and
|
||||
* leaves sharing off — that combination is supported on purpose.
|
||||
*/
|
||||
const SCREENTINKER_ENDPOINT = 'https://stats.screentinker.com/api/telemetry/report';
|
||||
const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily; this is a count, not a metric
|
||||
const FIRST_REPORT_DELAY_MS = 5 * 60 * 1000; // let boot settle before any outbound call
|
||||
|
||||
let timer = null;
|
||||
|
||||
/* The instance's own id, minted on first read. Stable for the life of the install. */
|
||||
function instanceId() {
|
||||
let id = appSettings.get(KEY_ID, null);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
appSettings.set(KEY_ID, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/*
|
||||
* 'unasked' | 'on' | 'off'. The distinction matters: 'unasked' is what the prompt keys on, and a
|
||||
* declined install must be remembered as 'off' rather than falling back to 'unasked' and being
|
||||
* asked again on every update — re-prompting is how telemetry gets patched out by annoyed admins.
|
||||
*/
|
||||
function state() {
|
||||
const v = appSettings.get(KEY_ENABLED, undefined);
|
||||
if (v === undefined) return 'unasked';
|
||||
return (v === 'true' || v === '1') ? 'on' : 'off';
|
||||
}
|
||||
|
||||
function setEnabled(enabled) {
|
||||
appSettings.setBool(KEY_ENABLED, !!enabled);
|
||||
return state();
|
||||
}
|
||||
|
||||
/* Every field that leaves this install, built in one place so it can be audited at a glance. */
|
||||
function payload(db) {
|
||||
return {
|
||||
instance_id: instanceId(),
|
||||
version: require('../version'),
|
||||
screen_count: countScreens(db),
|
||||
};
|
||||
}
|
||||
|
||||
// Devices that have actually been paired — a provisioning row nobody ever connected is not a
|
||||
// screen, and counting it would overstate exactly the number this exists to state honestly.
|
||||
function countScreens(db) {
|
||||
try {
|
||||
return db.prepare('SELECT COUNT(*) AS c FROM devices WHERE device_token IS NOT NULL').get().c;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The address an operator may need to allowlist for the shared report. Hard-wired. */
|
||||
function endpoint() { return SCREENTINKER_ENDPOINT; }
|
||||
|
||||
/* The operator's own collector, if they configured one. Null when they have not. */
|
||||
function extraEndpoint() { return process.env.TELEMETRY_EXTRA_ENDPOINT || null; }
|
||||
|
||||
/*
|
||||
* Everywhere this report is going, right now, and why — so the UI can list every destination
|
||||
* rather than implying there is only one. Sharing gates OUR endpoint alone.
|
||||
*/
|
||||
function destinations() {
|
||||
const out = [];
|
||||
if (state() === 'on') out.push({ url: endpoint(), kind: 'screentinker' });
|
||||
const extra = extraEndpoint();
|
||||
if (extra) out.push({ url: extra, kind: 'extra' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* What was last sent, and when. Surfaced in Settings so an operator can check rather than trust. */
|
||||
function getLastReport() {
|
||||
const raw = appSettings.get(KEY_LAST, null);
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/*
|
||||
* The last FAILED attempt, kept separately from the last success.
|
||||
*
|
||||
* A self-hosted server frequently sits behind egress filtering, so "enabled but nothing arrives"
|
||||
* is the normal failure and it is otherwise completely silent — the operator sees "nothing has
|
||||
* been sent" and has no way to tell a blocked firewall from a broken feature. Recording the
|
||||
* failure lets the UI name the host that needs unblocking instead.
|
||||
*/
|
||||
function getLastError() {
|
||||
const raw = appSettings.get(KEY_LAST_ERROR, null);
|
||||
if (!raw) return null; // '' is how a success clears it
|
||||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/*
|
||||
* Send one report. Returns {sent:false, reason} rather than throwing — a stats call must never be
|
||||
* able to affect the running server, so every failure path here is quiet and local.
|
||||
*/
|
||||
async function postTo(url, body) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return res.ok ? { sent: true } : { sent: false, reason: `http_${res.status}` };
|
||||
} catch (err) {
|
||||
// Offline, DNS failure, blocked egress — all normal for a self-hosted box, none of them news
|
||||
// in the log, but all worth surfacing in the UI so the operator can act on it.
|
||||
return { sent: false, reason: err && err.name === 'TimeoutError' ? 'timeout' : 'network' };
|
||||
}
|
||||
}
|
||||
|
||||
async function report(db, { urls = null } = {}) {
|
||||
// `urls` is a test seam. Normal callers get destinations() — sharing gates ours, an operator's
|
||||
// own collector is independent of it.
|
||||
const targets = urls || destinations();
|
||||
if (!targets.length) return { sent: false, reason: 'not_enabled', results: [] };
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const body = payload(db);
|
||||
|
||||
// Every destination is attempted, independently. One unreachable collector must not stop the
|
||||
// other from receiving — a blocked corporate firewall on their host should not cost us the
|
||||
// shared count, and our endpoint being down should not cost them their own fleet numbers.
|
||||
const results = [];
|
||||
for (const t of targets) results.push({ ...t, ...(await postTo(t.url, body)) });
|
||||
|
||||
const failed = results.filter(r => !r.sent);
|
||||
if (results.some(r => r.sent)) appSettings.set(KEY_LAST, JSON.stringify({ at: now(), body, results }));
|
||||
// Keep only a LIVE complaint: record what is still failing, and clear it once nothing is.
|
||||
appSettings.set(KEY_LAST_ERROR, failed.length
|
||||
? JSON.stringify({ at: now(), reason: failed[0].reason, url: failed[0].url, failed })
|
||||
: '');
|
||||
|
||||
return { sent: failed.length === 0, results, body, reason: failed[0]?.reason };
|
||||
}
|
||||
|
||||
function start(db) {
|
||||
if (timer) return;
|
||||
const tick = () => { report(db).catch(() => {}); };
|
||||
setTimeout(tick, FIRST_REPORT_DELAY_MS).unref?.();
|
||||
timer = setInterval(tick, REPORT_INTERVAL_MS);
|
||||
timer.unref?.(); // never hold the process open for a stats timer
|
||||
}
|
||||
|
||||
function stop() { if (timer) { clearInterval(timer); timer = null; } }
|
||||
|
||||
module.exports = { instanceId, state, setEnabled, payload, report, endpoint, extraEndpoint, destinations, getLastReport, getLastError, start, stop };
|
||||
108
server/lib/thumbnail-backfill.js
Normal file
108
server/lib/thumbnail-backfill.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
'use strict';
|
||||
|
||||
// Retroactive thumbnail generation. Ingest-time generation (lib/content-ingest) is
|
||||
// best-effort by contract, so a row silently ends up without a thumbnail whenever it
|
||||
// fails — most commonly video uploads on a host without ffmpeg installed, plus any
|
||||
// content from before thumbnails existed. Those rows previously stayed bare forever:
|
||||
// nothing ever looked at them again.
|
||||
//
|
||||
// This sweep runs once per boot (kicked off from server.js shortly after listen),
|
||||
// finds local image/video rows with no thumbnail, and re-derives metadata for each.
|
||||
// One file at a time with a pause between files: the point is to heal the library
|
||||
// eventually, not to win a race against playback serving on the same box.
|
||||
//
|
||||
// Idempotent by construction — a generated thumbnail fills thumbnail_path, which
|
||||
// removes the row from the next boot's query. Video rows are skipped wholesale when
|
||||
// ffmpeg/ffprobe are missing (the [MEDIA] startup diagnostic already told the
|
||||
// operator) rather than paying a doomed ffmpeg spawn per file per boot.
|
||||
//
|
||||
// The sweep can take a long time on a large bare library, and the replace/delete
|
||||
// flows may touch the same rows meanwhile. Two consequences handled below:
|
||||
// - the row UPDATE re-checks that thumbnail_path is STILL empty, so a thumbnail
|
||||
// written concurrently by PUT /:id/replace is never clobbered with a frame of
|
||||
// the pre-replace bytes;
|
||||
// - a row that vanished (or was replaced) mid-derive gets its just-written thumb
|
||||
// file removed again — contentDir has no garbage collector.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { db } = require('../db/database');
|
||||
const config = require('../config');
|
||||
const { deriveMediaMetadata } = require('./content-ingest');
|
||||
const { mediaToolStatus } = require('./media-tools');
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Undecodable files fail again every boot (thumbnail_path is the only idempotency
|
||||
// marker — deliberately, so installing ffmpeg heals them). A video can burn two 15s
|
||||
// subprocess timeouts, so a library with hundreds of corrupt clips must not turn
|
||||
// every boot into an hour of doomed ffmpeg spawns: stop after this many failures
|
||||
// and let the next boot take another bite.
|
||||
const FAILURE_CAP = 25;
|
||||
|
||||
async function backfillMissingThumbnails({ delayMs = 500 } = {}) {
|
||||
const tools = await mediaToolStatus();
|
||||
// filepath != '' and no remote_url: only content whose bytes live in contentDir.
|
||||
// Remote/YouTube/embed rows either carry a remote thumbnail URL already or have
|
||||
// nothing local to derive one from.
|
||||
const rows = db.prepare(`
|
||||
SELECT id, filepath, mime_type FROM content
|
||||
WHERE (thumbnail_path IS NULL OR thumbnail_path = '')
|
||||
AND filepath != ''
|
||||
AND (remote_url IS NULL OR remote_url = '')
|
||||
AND (mime_type LIKE 'image/%' OR mime_type LIKE 'video/%')
|
||||
`).all();
|
||||
|
||||
const stats = { scanned: rows.length, generated: 0, skipped: 0, failed: 0, aborted: false };
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE content SET thumbnail_path = ?,
|
||||
width = COALESCE(width, ?), height = COALESCE(height, ?),
|
||||
duration_sec = COALESCE(duration_sec, ?)
|
||||
WHERE id = ? AND (thumbnail_path IS NULL OR thumbnail_path = '')
|
||||
`);
|
||||
// Thumbnail failed but the probe worked: keep the dims/duration (item-duration and
|
||||
// orientation handling consume them) without marking the row healed — it stays
|
||||
// eligible for a thumbnail retry next boot.
|
||||
const metadataStmt = db.prepare(`
|
||||
UPDATE content SET width = COALESCE(width, ?), height = COALESCE(height, ?),
|
||||
duration_sec = COALESCE(duration_sec, ?)
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
if (stats.failed >= FAILURE_CAP) {
|
||||
stats.aborted = true;
|
||||
console.warn(`[MEDIA] thumbnail backfill: stopping after ${stats.failed} failures — will retry remaining rows next boot`);
|
||||
break;
|
||||
}
|
||||
const isVideo = row.mime_type.startsWith('video/');
|
||||
if (isVideo && (!tools.ffmpeg || !tools.ffprobe)) { stats.skipped++; continue; }
|
||||
const storedName = path.basename(row.filepath);
|
||||
const sourcePath = path.join(config.contentDir, storedName);
|
||||
if (!fs.existsSync(sourcePath)) { stats.skipped++; continue; }
|
||||
try {
|
||||
const { width, height, durationSec, thumbnailPath } =
|
||||
await deriveMediaMetadata(sourcePath, storedName, row.mime_type);
|
||||
if (thumbnailPath) {
|
||||
const res = updateStmt.run(thumbnailPath, width, height, durationSec, row.id);
|
||||
if (res.changes === 0 && thumbnailPath !== storedName) {
|
||||
// Row deleted/replaced while we were deriving. Don't leave the freshly
|
||||
// written file orphaned. (thumbnailPath === storedName is the SVG
|
||||
// self-thumbnail case — that file IS the content, never remove it.)
|
||||
try { fs.unlinkSync(path.join(config.contentDir, path.basename(thumbnailPath))); } catch { /* best-effort */ }
|
||||
}
|
||||
stats.generated += res.changes;
|
||||
} else {
|
||||
if (width || height || durationSec) metadataStmt.run(width, height, durationSec, row.id);
|
||||
stats.failed++; // deriveMediaMetadata already warned with the reason
|
||||
}
|
||||
} catch (e) {
|
||||
stats.failed++;
|
||||
console.warn(`Thumbnail backfill failed for ${row.id}: ${e.message}`);
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
module.exports = { backfillMissingThumbnails };
|
||||
62
server/lib/version-precedence.js
Normal file
62
server/lib/version-precedence.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Precedence for PRERELEASE identifiers — the `-alpha11` half of `1.9.34-alpha11`.
|
||||
*
|
||||
* ⚠️ WHY THIS EXISTS: a plain string compare is what semver specifies for a single alphanumeric
|
||||
* identifier, and it is wrong for how this project actually names builds. `"alpha11" < "alpha8"`
|
||||
* because `'1' < '8'`, so EVERY build from alpha10 onward sorted below alpha8 and alpha9. The OTA
|
||||
* check then answered `client-newer` and refused to offer the update at all — a fleet on alpha8
|
||||
* could not be moved forward, silently, with the server reporting the newer build as `latest` in
|
||||
* the same breath. Two comparators carried the same assumption, both with a comment saying lexical
|
||||
* was "fine for our naming"; it was fine only while the counter stayed below 10.
|
||||
*
|
||||
* The rule here is natural ordering: split each identifier into digit and non-digit runs and
|
||||
* compare digit runs NUMERICALLY. That gives what a human means by the name — alpha8 < alpha9 <
|
||||
* alpha10 < alpha11 — while leaving everything else alphabetical, so beta still outranks alpha and
|
||||
* rc still outranks beta.
|
||||
*
|
||||
* Dot-separated identifiers are compared one at a time per semver, and a shorter run of identifiers
|
||||
* loses when all preceding ones are equal (`alpha` < `alpha.1`), so a future move to the semver-
|
||||
* correct `-alpha.11` form keeps working without another change here.
|
||||
*
|
||||
* Deliberately NOT handled: whether a prerelease outranks a release. That is the caller's rule —
|
||||
* both callers already implement it, and each has its own exceptions (ota-breaker treats the legacy
|
||||
* `-patchN` scheme as released).
|
||||
*/
|
||||
|
||||
// Compare one identifier, digit runs numerically. "alpha10" -> ["alpha", "10"].
|
||||
function naturalCmp(x, y) {
|
||||
const rx = String(x).match(/\d+|\D+/g) || [];
|
||||
const ry = String(y).match(/\d+|\D+/g) || [];
|
||||
for (let i = 0; i < Math.max(rx.length, ry.length); i++) {
|
||||
const a = rx[i], b = ry[i];
|
||||
if (a === undefined) return -1; // "alpha" < "alpha1"
|
||||
if (b === undefined) return 1;
|
||||
const aNum = /^\d+$/.test(a), bNum = /^\d+$/.test(b);
|
||||
if (aNum && bNum) {
|
||||
// Numeric, so 10 beats 8 — the whole point of this file.
|
||||
if (Number(a) !== Number(b)) return Number(a) < Number(b) ? -1 : 1;
|
||||
} else if (a !== b) {
|
||||
// A digit run sorts below a word run, matching semver's numeric-identifiers-first rule.
|
||||
if (aNum !== bNum) return aNum ? -1 : 1;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Full prerelease precedence: dot-separated identifiers, each compared naturally. */
|
||||
function preCmp(a, b) {
|
||||
if (a === b) return 0;
|
||||
const as = String(a).split('.'), bs = String(b).split('.');
|
||||
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
||||
if (as[i] === undefined) return -1; // "alpha" < "alpha.1"
|
||||
if (bs[i] === undefined) return 1;
|
||||
const c = naturalCmp(as[i], bs[i]);
|
||||
if (c !== 0) return c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = { preCmp, naturalCmp };
|
||||
187
server/lib/wall-geometry.js
Normal file
187
server/lib/wall-geometry.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Video-wall tile geometry: where does one panel's stage sit inside its own viewport?
|
||||
*
|
||||
* #236. Before per-panel rotation existed, the wall canvas was secretly drawn in FRAMEBUFFER
|
||||
* space, not in the space a person standing in front of the wall sees. That is invisible while
|
||||
* every panel is mounted the normal way up, and actively misleading the moment one isn't: a
|
||||
* customer with two portrait-mounted panels SIDE BY SIDE had to stack them VERTICALLY in the
|
||||
* editor and ship a pre-rotated copy of every video, because the editor was really asking
|
||||
* "where is this panel's 1920x1080 framebuffer?" while showing a picture that read as
|
||||
* "where is this panel on the wall?".
|
||||
*
|
||||
* The model here: the canvas is WALL space — x right, y down, as the audience sees it. A panel
|
||||
* mounted turned occupies a turned rect (a portrait-mounted 1920x1080 panel is a tall tile), and
|
||||
* `rotation` says how far the panel's own image has to be turned to come out upright on the wall.
|
||||
*
|
||||
* rotation is degrees CLOCKWISE that the content is rotated WITHIN the framebuffer — the same
|
||||
* convention as the per-device `orientation` field (lib/orientation-style.js: portrait === 90).
|
||||
* Equivalently: the panel is physically mounted rotated that far ANTI-clockwise. Picking the
|
||||
* opposite sign here would have been just as self-consistent and would have silently disagreed
|
||||
* with the device orientation setting, so it is pinned by test.
|
||||
*
|
||||
* The arithmetic lives here, once, because four players have to agree on it to the pixel — the web
|
||||
* player, Tizen, Android and BrightSign all render the same frame across panels that share a seam.
|
||||
* A half-pixel of disagreement between two of them is a visible line down the middle of the wall.
|
||||
*/
|
||||
|
||||
const VALID_ROTATIONS = [0, 90, 180, 270];
|
||||
|
||||
/**
|
||||
* Coerce whatever the DB / payload carried into a rotation we can render.
|
||||
* Anything unrecognised falls back to 0: a bad value should leave the wall looking exactly as it
|
||||
* was drawn, not turn one panel of a live wall on its side.
|
||||
*/
|
||||
function normalizeWallRotation(value) {
|
||||
const n = Number(value);
|
||||
return VALID_ROTATIONS.includes(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which orientation should the player apply to its container while it is a member of a wall?
|
||||
*
|
||||
* The two settings describe the SAME physical fact (this panel is mounted turned), so applying
|
||||
* both turns the content twice and lands it sideways and off-screen. When the wall carries a
|
||||
* rotation it is authoritative — the tile geometry below already accounts for the mounting — so
|
||||
* the container transform is suppressed. Rotation 0 changes nothing, which is what keeps every
|
||||
* wall that exists today behaving exactly as it does today.
|
||||
*
|
||||
* @param {string} orientation the device's own orientation setting
|
||||
* @param {number} wallRotation per-panel wall rotation (0/90/180/270)
|
||||
* @returns {string} the orientation the player should actually apply
|
||||
*/
|
||||
function orientationForWallMember(orientation, wallRotation) {
|
||||
return normalizeWallRotation(wallRotation) === 0 ? (orientation || 'landscape') : 'landscape';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile geometry in viewport-relative units.
|
||||
*
|
||||
* The stage is the WHOLE player rect; the viewport crops it to this panel's slice. So the stage is
|
||||
* usually much larger than the screen and usually positioned partly off-view — that is the design,
|
||||
* not a bug.
|
||||
*
|
||||
* Returned as unit-tagged numbers so the same numbers can drive CSS (vw/vh) and Android
|
||||
* (displayMetrics px) without either re-deriving the maths.
|
||||
*
|
||||
* @param {{x:number,y:number,w:number,h:number}} screenRect this panel's rect in wall space
|
||||
* @param {{x:number,y:number,w:number,h:number}} playerRect the content rect in wall space
|
||||
* @param {number} rotation 0/90/180/270
|
||||
* @returns {null|{rotation:number,w:number,wAxis:'x'|'y',h:number,hAxis:'x'|'y',cx:number,cy:number}}
|
||||
* w/h — the stage box BEFORE rotation, as a multiple of the viewport axis named by wAxis/hAxis
|
||||
* ('x' = viewport width, 'y' = viewport height).
|
||||
* cx/cy— where the box's centre goes, as a fraction of viewport width / height.
|
||||
* null when the screen rect has no area (nothing sane to map onto a zero-sized panel).
|
||||
*/
|
||||
function wallStageGeometry(screenRect, playerRect, rotation) {
|
||||
const s = screenRect, p = playerRect;
|
||||
if (!s || !p || !s.w || !s.h) return null;
|
||||
const rot = normalizeWallRotation(rotation);
|
||||
|
||||
// The player rect's centre, as a fraction of this panel's rect. Working from the CENTRE (not the
|
||||
// top-left) is what makes all four rotations one formula: rotation moves a corner but leaves a
|
||||
// centre where it is.
|
||||
const nx = (p.x + p.w / 2 - s.x) / s.w;
|
||||
const ny = (p.y + p.h / 2 - s.y) / s.h;
|
||||
const fw = p.w / s.w; // stage extent along wall X, in units of the panel's wall width
|
||||
const fh = p.h / s.h; // stage extent along wall Y, in units of the panel's wall height
|
||||
|
||||
// A quarter turn swaps which viewport axis each wall axis is measured against: on a panel mounted
|
||||
// sideways, the wall's horizontal is the framebuffer's vertical. Getting this wrong is the classic
|
||||
// "the wall is right but every tile is squashed" symptom.
|
||||
const quarter = rot === 90 || rot === 270;
|
||||
const wAxis = quarter ? 'y' : 'x';
|
||||
const hAxis = quarter ? 'x' : 'y';
|
||||
|
||||
// Where wall-space (nx, ny) lands in framebuffer-normalised (across, down) coordinates.
|
||||
// Derivation: rotating content by `rot` clockwise inside the framebuffer sends the wall's
|
||||
// top-left corner to the framebuffer corner listed, and the wall axes to the framebuffer axes
|
||||
// listed. Each case is pinned by a test.
|
||||
let cx, cy;
|
||||
if (rot === 90) {
|
||||
// wall +X -> framebuffer down, wall +Y -> framebuffer left; wall origin at framebuffer top-right
|
||||
cx = 1 - ny; cy = nx;
|
||||
} else if (rot === 180) {
|
||||
cx = 1 - nx; cy = 1 - ny;
|
||||
} else if (rot === 270) {
|
||||
// wall +X -> framebuffer up, wall +Y -> framebuffer right; wall origin at framebuffer bottom-left
|
||||
cx = ny; cy = 1 - nx;
|
||||
} else {
|
||||
cx = nx; cy = ny;
|
||||
}
|
||||
|
||||
return { rotation: rot, w: fw, wAxis, h: fh, hAxis, cx, cy };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same geometry as CSS values, ready to assign onto element.style.
|
||||
* Empty string means "clear it" — a half-reset leaves a stage stuck at the previous wall's size.
|
||||
*
|
||||
* @returns {{left:string,top:string,width:string,height:string,transform:string,transformOrigin:string}}
|
||||
* or null when the screen rect has no area.
|
||||
*/
|
||||
function wallStageStyle(screenRect, playerRect, rotation) {
|
||||
const s = screenRect, p = playerRect;
|
||||
if (!s || !p || !s.w || !s.h) return null;
|
||||
|
||||
// Unrotated walls take the ORIGINAL top-left expression verbatim, not the centre-based one below.
|
||||
// The two are algebraically equal but not bit-for-bit equal in floating point, and every wall in
|
||||
// the field today is rotation 0. An operator upgrading must not find a hairline seam appear down
|
||||
// a wall that was aligned yesterday, so this path is deliberately left untouched.
|
||||
if (normalizeWallRotation(rotation) === 0) {
|
||||
return {
|
||||
left: (((p.x - s.x) / s.w) * 100) + 'vw',
|
||||
top: (((p.y - s.y) / s.h) * 100) + 'vh',
|
||||
width: ((p.w / s.w) * 100) + 'vw',
|
||||
height: ((p.h / s.h) * 100) + 'vh',
|
||||
transform: '',
|
||||
transformOrigin: '',
|
||||
};
|
||||
}
|
||||
|
||||
const g = wallStageGeometry(s, p, rotation);
|
||||
const unit = (axis) => (axis === 'x' ? 'vw' : 'vh');
|
||||
return {
|
||||
left: (g.cx * 100) + 'vw',
|
||||
top: (g.cy * 100) + 'vh',
|
||||
width: (g.w * 100) + unit(g.wAxis),
|
||||
height: (g.h * 100) + unit(g.hAxis),
|
||||
// translate BEFORE rotate: transform functions apply right-to-left, so the box turns about its
|
||||
// own centre and THEN that centre is moved into place. Reversed, the offset is rotated too and
|
||||
// the tile lands on the wrong side of the panel (the same trap as orientation-style.js).
|
||||
transform: 'translate(-50%, -50%) rotate(' + g.rotation + 'deg)',
|
||||
transformOrigin: 'center center',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall footprint of a panel whose framebuffer is renderW x renderH, once mounted at `rotation`.
|
||||
* The editor sizes new tiles with this so a portrait-mounted 1920x1080 panel is drawn as the tall
|
||||
* rect it physically is — which is the whole point of #236.
|
||||
*/
|
||||
function rotatedFootprint(renderW, renderH, rotation) {
|
||||
const rot = normalizeWallRotation(rotation);
|
||||
return (rot === 90 || rot === 270) ? { w: renderH, h: renderW } : { w: renderW, h: renderH };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
VALID_ROTATIONS,
|
||||
normalizeWallRotation,
|
||||
orientationForWallMember,
|
||||
wallStageGeometry,
|
||||
wallStageStyle,
|
||||
rotatedFootprint,
|
||||
};
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WallGeometry = {
|
||||
VALID_ROTATIONS,
|
||||
normalizeWallRotation,
|
||||
orientationForWallMember,
|
||||
wallStageGeometry,
|
||||
wallStageStyle,
|
||||
rotatedFootprint,
|
||||
};
|
||||
}
|
||||
1031
server/package-lock.json
generated
1031
server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.29-rc4",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"description": "ScreenTinker - Digital Signage Management Server",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -11,20 +12,21 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.2.1",
|
||||
"@jsquash/avif": "^1.3.0",
|
||||
"@jsquash/webp": "^1.5.0",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"better-sqlite3": "12.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.35.3",
|
||||
"socket.io": "^4.7.2",
|
||||
"stripe": "^20.4.1",
|
||||
"unzipper": "^0.12.3",
|
||||
|
|
@ -32,7 +34,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"js-yaml": "^4.2.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"puppeteer-core": "^24.43.1"
|
||||
"puppeteer-core": "^24.43.1",
|
||||
"sharp": "^0.35.3",
|
||||
"socket.io-client": "^4.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@
|
|||
try { return Date.now(); } catch (e) { return new Date().getTime(); }
|
||||
}
|
||||
|
||||
// Live subscribers (the player's set_debug sink). Kept HERE rather than in the player
|
||||
// script so entries recorded during boot -- before the player has even parsed -- reach a
|
||||
// subscriber that registers later, via the backlog in window.__debugLog.
|
||||
var subs = [];
|
||||
var notifying = false;
|
||||
|
||||
function pushLog(entry) {
|
||||
try {
|
||||
entry.t = nowMs();
|
||||
|
|
@ -32,8 +38,22 @@
|
|||
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
|
||||
}
|
||||
} catch (e) { /* we are the safety net; do not crash */ }
|
||||
// Reentrancy guard, and it is not theoretical: this runs inside the console wrapper
|
||||
// below, so a subscriber that logs anything at all -- directly, or through a library --
|
||||
// would call back in here and recurse until the stack gave out. The player would die of
|
||||
// its own diagnostics.
|
||||
if (notifying) return;
|
||||
notifying = true;
|
||||
try {
|
||||
for (var s = 0; s < subs.length; s++) {
|
||||
try { subs[s](entry); } catch (e) { /* one bad subscriber must not eat the rest */ }
|
||||
}
|
||||
} finally { notifying = false; }
|
||||
}
|
||||
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
|
||||
window.__debugLog_subscribe = function (fn) {
|
||||
try { if (typeof fn === 'function') subs.push(fn); } catch (e) {}
|
||||
};
|
||||
|
||||
pushLog({
|
||||
type: 'init',
|
||||
|
|
@ -126,6 +146,26 @@
|
|||
<title>ScreenTinker Player</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/*
|
||||
* ONE KNOB for every pre-playback screen (setup, pairing, status, the audio prompt).
|
||||
*
|
||||
* A signage panel is read from across a room, so the thing that has to stay constant is
|
||||
* ANGULAR size, not pixel size — and a CSS pixel covers a quarter of the screen area on a 4K
|
||||
* panel that it does on 1080p, a sixteenth on 8K. A 72px pairing code that fills the wall on a
|
||||
* 1080p screen is a smudge on an 8K one, which is exactly the complaint this fixes.
|
||||
*
|
||||
* So every size on those screens is a rem against this root: 1rem = 10px at 1080p, 20px at 4K,
|
||||
* 40px at 8K — the same apparent size at the same viewing distance, at any panel resolution.
|
||||
*
|
||||
* vmin rather than vw, because portrait-mounted panels are common here and vw would render a
|
||||
* 1080x1920 screen at half size. Clamped at both ends so a dashboard preview iframe or a
|
||||
* laptop window stays legible instead of microscopic, and an ultrawide does not get silly.
|
||||
*
|
||||
* Nothing outside these screens uses rem, so this cannot reach playback content — zones,
|
||||
* images and video are laid out in % and px by the layout engine and are untouched.
|
||||
*/
|
||||
html { font-size: clamp(7px, 0.926vmin, 56px); }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
|
||||
|
||||
/* Setup Screen */
|
||||
|
|
@ -133,23 +173,25 @@
|
|||
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
|
||||
}
|
||||
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
|
||||
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
|
||||
#setupScreen .form { width: 400px; max-width: 90vw; }
|
||||
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
|
||||
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
|
||||
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
|
||||
#setupScreen h1 { font-size: 3.6rem; color: #3b82f6; margin-bottom: 0.8rem; }
|
||||
#setupScreen .subtitle { color: #94a3b8; font-size: 1.6rem; margin-bottom: 4.8rem; }
|
||||
#setupScreen .form { width: 40rem; max-width: 90vw; }
|
||||
#setupScreen label { display: block; font-size: 1.4rem; color: #94a3b8; margin-bottom: 0.8rem; }
|
||||
#setupScreen input { width: 100%; padding: 1.2rem; background: #0f172a; border: 0.1rem solid #334155;
|
||||
border-radius: 0.8rem; color: #f1f5f9; font-size: 1.6rem; margin-bottom: 2.4rem; outline: none; }
|
||||
#setupScreen input:focus { border-color: #3b82f6; }
|
||||
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
|
||||
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||||
#setupScreen button { width: 100%; padding: 1.2rem; background: #3b82f6; color: white;
|
||||
border: none; border-radius: 0.8rem; font-size: 1.6rem; font-weight: 600; cursor: pointer; }
|
||||
#setupScreen button:hover { background: #2563eb; }
|
||||
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
|
||||
letter-spacing: 12px; margin: 24px 0; }
|
||||
.pairing-hint { color: #64748b; font-size: 14px; }
|
||||
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
|
||||
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
|
||||
/* The number someone is squinting at from the far side of a shop. It gets the most room the
|
||||
screen can give it, which is why it is the largest multiple here. */
|
||||
.pairing-code { font-size: 7.2rem; font-weight: 700; color: #3b82f6; font-family: monospace;
|
||||
letter-spacing: 1.2rem; margin: 2.4rem 0; }
|
||||
.pairing-hint { color: #64748b; font-size: 1.4rem; }
|
||||
.status-msg { color: #94a3b8; font-size: 1.4rem; margin-top: 1.6rem; }
|
||||
.spinner { width: 4rem; height: 4rem; border: 0.3rem solid #334155; border-top-color: #3b82f6;
|
||||
border-radius: 50%; animation: spin 1s linear infinite; margin: 2.4rem auto; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Player */
|
||||
|
|
@ -201,8 +243,8 @@
|
|||
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
|
||||
}
|
||||
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
|
||||
#statusOverlay p { font-size: 16px; }
|
||||
#statusOverlay h2 { color: #3b82f6; font-size: 2.8rem; margin-bottom: 0.8rem; }
|
||||
#statusOverlay p { font-size: 1.6rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -241,6 +283,7 @@
|
|||
<script src="/player/schedule-eval.js"></script>
|
||||
<script src="/player/media-mute.js"></script>
|
||||
<script src="/player/orientation-style.js"></script>
|
||||
<script src="/player/wall-geometry.js"></script>
|
||||
<script src="/player/player-media-health.js"></script>
|
||||
<!-- feat/transition-engine: WebGL transition runtime (renderer + shaders). Optional; if it fails to
|
||||
load the player just hard-cuts. Not deferred so it's ready before the first content swap. -->
|
||||
|
|
@ -492,13 +535,233 @@
|
|||
// ==================== State ====================
|
||||
let socket = null;
|
||||
let config = getConfig();
|
||||
|
||||
/*
|
||||
* Does a <video> on this platform actually yield pixels to a canvas? Cached: it is a property
|
||||
* of the platform, not of the clip. Consumed by videoCompositingAvailable() ~2900 lines below.
|
||||
*
|
||||
* DECLARED HERE, AND IT MUST STAY HERE. It used to live next to its function, and that BRICKED
|
||||
* a player: boot restores the cached playlist and renders item 0 from a call site far above
|
||||
* that point, so when item 0 was a video carrying a transition, `isVideoBufferable` read this
|
||||
* binding before its `let` had executed. That is a TemporalDeadZone *throw*, not a `null` —
|
||||
* the player died during boot, every boot, and because the offending playlist came from the
|
||||
* LOCAL cache it never stayed up long enough to receive a corrected one. A permanent brick,
|
||||
* recoverable only by clearing the device's storage.
|
||||
*
|
||||
* Reproduced on a BrightSign XT245 on 2026-08-07: "Cannot access '_videoCompositingOk' before
|
||||
* initialization @ player:3730". Any web-based player could hit it — it is not BrightSign-specific.
|
||||
*/
|
||||
let _videoCompositingOk = null;
|
||||
// feat/offline-cause-log: connectivity-report state — track in-session disconnects so the next
|
||||
// reconnect can report WHY it was gone (local link lost vs server/upstream unreachable). A browser
|
||||
// can't see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false.
|
||||
let disconnectedAt = 0; // Date.now() at the first disconnect of the current gap (0 = not in a gap)
|
||||
let linkLostDuringGap = false; // navigator went offline at any point during the gap
|
||||
// Set when register() is REFUSED (not merely pending). A runtime can expose navigator
|
||||
// .serviceWorker and still decline to run one — a real BrightSign widget does exactly that —
|
||||
// and the display must stop claiming an offline capability it cannot honour.
|
||||
let swRegistrationFailed = false;
|
||||
// feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
|
||||
// Best-effort + auth-guarded (the reconnected socket is authenticated by the time we emit).
|
||||
/*
|
||||
* BrightSign host diagnostics, forwarded as if the page had produced them.
|
||||
*
|
||||
* The host sees what the page cannot — the uptime, the wired IP, which volume it booted from,
|
||||
* whether a staged package applied — and everything it knew used to go to a serial console.
|
||||
* On a panel on a wall that is the same as knowing nothing: a script that failed to compile
|
||||
* looked, from here, exactly like a player that never started.
|
||||
*
|
||||
* Wired once, guarded, and a no-op on every other platform: the hooks only exist on the
|
||||
* BrightSign bridge, so a browser skips this entirely.
|
||||
*/
|
||||
function wireHostDiagnostics() {
|
||||
try {
|
||||
if (!BS || typeof BS.onHostLog !== 'function') return;
|
||||
BS.onHostLog((line) => {
|
||||
try {
|
||||
// While the live stream is ON the console.log below already reaches the dashboard
|
||||
// through the debug sink, so emitting here too would show every host line twice.
|
||||
// Host lines still go out unconditionally when it is OFF: the boot story is the one
|
||||
// diagnostic nobody can ask for in advance, because it is over before the operator
|
||||
// has a device to open.
|
||||
if (!remoteDebug && socket?.connected && config.deviceId) {
|
||||
socket.emit('device:log', {
|
||||
device_id: config.deviceId,
|
||||
tag: line.tag, level: line.level, message: line.message
|
||||
});
|
||||
}
|
||||
console.log(`[host/${line.tag}] ${line.message}`);
|
||||
} catch (e) { /* diagnostics must never break playback */ }
|
||||
});
|
||||
BS.onHostEvent((ev) => emitDeviceEvent(ev.event, ev.reason, ev.detail));
|
||||
} catch (e) { /* a bridge that throws here must not stop the player starting */ }
|
||||
}
|
||||
|
||||
/* ==================== Live remote debug (`set_debug`) ====================
|
||||
*
|
||||
* The dashboard's per-device "Debug logging" checkbox sends a `set_debug` command and opens a
|
||||
* live panel. The Android player has honoured it for a long time -- DebugLog.* mirrors its
|
||||
* tagged lines to the dashboard while the box is ticked. The WEB player never implemented the
|
||||
* command at all, so the panel opened, streamed nothing, and read as a display with nothing to
|
||||
* say. In a browser that barely mattered: press F12. On a BrightSign it is the whole story --
|
||||
* there is no console, no adb, no logcat, and this panel is the only way to watch what the
|
||||
* player thinks it is doing.
|
||||
*
|
||||
* Rather than hand-instrument eighty-odd call sites to match Android's tag-by-tag, this streams
|
||||
* the ring buffer the error trap at the top of <head> has always filled: every console.log/
|
||||
* warn/error, every uncaught error with file:line and stack, every unhandled rejection, every
|
||||
* failed resource load, plus the host's own boot report on BrightSign. Turning the stream on
|
||||
* also FLUSHES that backlog, so the operator sees the failure that happened BEFORE they opened
|
||||
* the screen -- which is the case they actually came to investigate, and which tailing a log
|
||||
* cannot give them.
|
||||
*/
|
||||
let remoteDebug = false; // is the stream on right now
|
||||
let debugSubscribed = false; // ring-buffer subscription is permanent once made
|
||||
let debugAutoOffTimer = null;
|
||||
let debugWindowAt = 0, debugWindowCount = 0, debugSuppressed = 0;
|
||||
|
||||
// Fed by console.*, so the ceiling has to assume the worst: a video that fails to decode and is
|
||||
// retried every frame turns a diagnostic aid into a flood down the same socket as playback.
|
||||
const DEBUG_MAX_LINES_PER_SEC = 40;
|
||||
// Ticking the box and walking away must not leave a panel streaming for the rest of the week.
|
||||
// The dashboard turns it off on teardown, but a closed laptop or a killed tab never sends that,
|
||||
// and the device is the only party in a position to be sure.
|
||||
const DEBUG_AUTO_OFF_MS = 30 * 60 * 1000;
|
||||
|
||||
function debugSend(tag, level, message) {
|
||||
if (!socket?.connected || !config.deviceId) return;
|
||||
try {
|
||||
socket.emit('device:log', {
|
||||
device_id: config.deviceId,
|
||||
tag: String(tag || 'player').slice(0, 64),
|
||||
level: String(level || 'i').slice(0, 8),
|
||||
message: String(message == null ? '' : message).slice(0, 2000),
|
||||
});
|
||||
} catch (e) { /* a diagnostic that throws is worse than one that is missing */ }
|
||||
}
|
||||
|
||||
/*
|
||||
* One live line, rate-limited. Over the cap the lines are COUNTED, not queued -- an operator
|
||||
* needs to know output was dropped far more than they need the four hundredth copy of one
|
||||
* message, and a queue would go on replaying the flood after it stopped.
|
||||
*/
|
||||
function debugEmitLine(tag, level, message) {
|
||||
if (!remoteDebug) return;
|
||||
const now = Date.now();
|
||||
if (now - debugWindowAt >= 1000) {
|
||||
debugWindowAt = now;
|
||||
const dropped = debugSuppressed;
|
||||
debugWindowCount = 0;
|
||||
debugSuppressed = 0;
|
||||
if (dropped > 0) {
|
||||
debugWindowCount++;
|
||||
debugSend('debug', 'w', `${dropped} line(s) suppressed — rate limit`);
|
||||
}
|
||||
}
|
||||
if (debugWindowCount >= DEBUG_MAX_LINES_PER_SEC) { debugSuppressed++; return; }
|
||||
debugWindowCount++;
|
||||
debugSend(tag, level, message);
|
||||
}
|
||||
|
||||
// The player already prefixes most of its console lines with [wall], [bs], [group-sync] and so
|
||||
// on -- the same shape Android's tags have -- so lift that into the tag column and the panel
|
||||
// reads the same on both platforms instead of being one long undifferentiated column.
|
||||
const DEBUG_TAG_RE = /^\s*\[([a-zA-Z0-9/_.-]{1,24})\]\s*/;
|
||||
function debugTagFor(entry) {
|
||||
if (entry.tag) return String(entry.tag);
|
||||
const m = DEBUG_TAG_RE.exec(entry.message || '');
|
||||
if (m) return m[1];
|
||||
if (entry.type === 'error' || entry.type === 'rejection') return 'error';
|
||||
if (entry.type === 'timing' || entry.type === 'init') return entry.type;
|
||||
return 'player';
|
||||
}
|
||||
|
||||
function debugLevelFor(entry) {
|
||||
if (entry.level) return entry.level;
|
||||
if (entry.type === 'console.error' || entry.type === 'error' || entry.type === 'rejection') return 'e';
|
||||
if (entry.type === 'console.warn') return 'w';
|
||||
return 'i';
|
||||
}
|
||||
|
||||
// The non-console entry types carry structured fields rather than a message, and they are the
|
||||
// valuable ones -- an uncaught error is worth nothing without its file, line and stack.
|
||||
function debugMessageFor(entry) {
|
||||
let msg = entry.message || '';
|
||||
if (entry.type === 'init') {
|
||||
msg = `page ${entry.url || '?'} — screen ${entry.sw || '?'}x${entry.sh || '?'} — ${entry.ua || ''}`;
|
||||
} else if (entry.type === 'timing') {
|
||||
msg = `${entry.event} +${entry.sinceInit}ms`;
|
||||
} else if (entry.type === 'error' || entry.type === 'rejection') {
|
||||
if (entry.source) msg += ` @${entry.source}:${entry.line || 0}:${entry.col || 0}`;
|
||||
if (entry.stack) msg += ` | ${String(entry.stack).replace(/\s+/g, ' ').slice(0, 400)}`;
|
||||
} else if (!msg) {
|
||||
msg = entry.type || '';
|
||||
}
|
||||
return msg.replace(DEBUG_TAG_RE, ''); // already lifted into the tag column
|
||||
}
|
||||
|
||||
function debugSink(entry) {
|
||||
if (!remoteDebug || !entry || entry.__stSent) return;
|
||||
entry.__stSent = true;
|
||||
debugEmitLine(debugTagFor(entry), debugLevelFor(entry), debugMessageFor(entry));
|
||||
}
|
||||
|
||||
// What the operator needs before the first line means anything: which player this is, how big
|
||||
// the screen really is, and -- on BrightSign -- that they are talking to the host at all.
|
||||
function debugPlatformLine() {
|
||||
const bits = [];
|
||||
try { bits.push(`screen ${screen.width}x${screen.height}@${window.devicePixelRatio || 1}x`); } catch (e) {}
|
||||
try { if (BS && BS.isBrightSign && BS.isBrightSign()) bits.push('BrightSign'); } catch (e) {}
|
||||
try { bits.push(navigator.userAgent); } catch (e) {}
|
||||
return bits.join(' — ');
|
||||
}
|
||||
|
||||
/*
|
||||
* Replay what is already in the buffer. Deliberately bypasses the per-second cap: it is a
|
||||
* one-shot burst bounded by the ring buffer itself (200 entries), and throttling it would drop
|
||||
* exactly the history the operator turned the stream on to read.
|
||||
*/
|
||||
function debugFlushBacklog() {
|
||||
let buf = [];
|
||||
try { buf = (window.__debugLog || []).filter((e) => e && !e.__stSent); } catch (e) { return; }
|
||||
debugSend('debug', 'i', `--- replaying ${buf.length} buffered line(s) from before the stream opened ---`);
|
||||
const now = Date.now();
|
||||
for (const entry of buf) {
|
||||
entry.__stSent = true;
|
||||
// The dashboard timestamps each line on arrival, so a replayed line would claim to have
|
||||
// happened just now. Carry the real age in the text instead of quietly lying about when
|
||||
// the crash was.
|
||||
const age = entry.t ? `(-${((now - entry.t) / 1000).toFixed(1)}s) ` : '';
|
||||
debugSend(debugTagFor(entry), debugLevelFor(entry), age + debugMessageFor(entry));
|
||||
}
|
||||
debugSend('debug', 'i', '--- end of backlog, now live ---');
|
||||
}
|
||||
|
||||
function setRemoteDebug(on) {
|
||||
if (debugAutoOffTimer) { clearTimeout(debugAutoOffTimer); debugAutoOffTimer = null; }
|
||||
if (!on) {
|
||||
if (remoteDebug) debugEmitLine('debug', 'i', 'Remote debug logging OFF');
|
||||
remoteDebug = false;
|
||||
return;
|
||||
}
|
||||
const wasOn = remoteDebug;
|
||||
remoteDebug = true;
|
||||
debugWindowAt = 0; debugWindowCount = 0; debugSuppressed = 0;
|
||||
if (!debugSubscribed) {
|
||||
debugSubscribed = true;
|
||||
try { window.__debugLog_subscribe?.(debugSink); } catch (e) {}
|
||||
}
|
||||
if (!wasOn) {
|
||||
debugSend('debug', 'i', `Remote debug logging ON — ${debugPlatformLine()}`);
|
||||
debugFlushBacklog();
|
||||
}
|
||||
debugAutoOffTimer = setTimeout(() => {
|
||||
debugAutoOffTimer = null;
|
||||
debugEmitLine('debug', 'w', `Remote debug logging auto-disabled after ${Math.round(DEBUG_AUTO_OFF_MS / 60000)} min`);
|
||||
remoteDebug = false;
|
||||
}, DEBUG_AUTO_OFF_MS);
|
||||
}
|
||||
|
||||
function emitDeviceEvent(type, reason, detail) {
|
||||
try {
|
||||
if (!socket?.connected || !config.deviceId) return;
|
||||
|
|
@ -608,6 +871,51 @@
|
|||
// playback muted).
|
||||
let userHasInteracted = false;
|
||||
let advanceTimer = null;
|
||||
|
||||
/*
|
||||
* Arm the single advance timer. ALWAYS clear the outgoing one first.
|
||||
*
|
||||
* `advanceTimer` is one slot by design — renderContent clears it on every item change — but a
|
||||
* dozen call sites used to write `advanceTimer = setTimeout(...)` directly, and a second write
|
||||
* before the first fired ORPHANED the earlier timer instead of cancelling it: still pending,
|
||||
* no longer referenced, so nothing could stop it. Every one of those fired, and each one
|
||||
* advanced the playlist.
|
||||
*/
|
||||
function scheduleAdvance(fn, ms) {
|
||||
if (advanceTimer) clearTimeout(advanceTimer);
|
||||
advanceTimer = setTimeout(fn, ms);
|
||||
return advanceTimer;
|
||||
}
|
||||
|
||||
const MEDIA_ERR_NAME = { 1: 'ABORTED', 2: 'NETWORK', 3: 'DECODE', 4: 'SRC_NOT_SUPPORTED' };
|
||||
|
||||
/*
|
||||
* One broken item, one skip.
|
||||
*
|
||||
* A media element can raise `error` several times over for a single item, and each event used
|
||||
* to schedule its own `nextItem`. On a SINGLE-item playlist that merely re-played the same
|
||||
* clip, which is how this was finally noticed — a BrightSign XT245 logging four errors and
|
||||
* three "Playing:" lines at every loop boundary. On a real playlist the identical storm SKIPS
|
||||
* one item per surplus event, silently, and the operator sees a playlist that drops content.
|
||||
*
|
||||
* The second guard is that a media element still able to play is not a failure. `error` fires
|
||||
* with `el.error` set; an event carrying no MediaError against an element with buffered frames
|
||||
* ahead of it did not fail at anything, and discarding a healthy item on that basis is worse
|
||||
* than the event we are reacting to. Anything genuinely unplayable (no MediaError AND nothing
|
||||
* decoded) is still skipped, so a broken clip can never stall the playlist.
|
||||
*/
|
||||
function mediaFailureSkip(el, label, src, mayAdvance = true) {
|
||||
const err = el && el.error;
|
||||
if (!err && el && el.readyState >= 3) {
|
||||
console.warn(`[media] ${label} raised error with no MediaError while playable (readyState=${el.readyState}) — ignored: ${src}`);
|
||||
return;
|
||||
}
|
||||
if (el && el.__stFailed) return; // however many events it raises, one skip
|
||||
if (el) el.__stFailed = true;
|
||||
const detail = err ? `code=${err.code} ${MEDIA_ERR_NAME[err.code] || '?'}${err.message ? ' ' + err.message : ''}` : 'no MediaError';
|
||||
console.error(`[media] ${label} failed (${detail}) src=${src}`);
|
||||
if (mayAdvance) scheduleAdvance(nextItem, 3000); // skip the broken item; hold the prior frame
|
||||
}
|
||||
// Buffered widget swap (#directory-board black-cycle): build the next widget iframe
|
||||
// behind the current content and reveal it only on 'load', so a widget reload never
|
||||
// blanks the screen. WIDGET_SWAP_TIMEOUT_MS reveals anyway if 'load' never fires (a
|
||||
|
|
@ -807,8 +1115,8 @@
|
|||
if (document.getElementById('enableAudioPrompt')) return;
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'enableAudioPrompt';
|
||||
ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
|
||||
ov.innerHTML = '<span style="font-size:20px">🔇</span><span>Tap to enable audio</span>';
|
||||
ov.style.cssText = 'position:fixed;bottom:2.4rem;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:1.2rem 2.2rem;border-radius:0.8rem;cursor:pointer;z-index:10000;font-size:1.4rem;display:flex;gap:1rem;align-items:center;box-shadow:0 0.4rem 1.6rem rgba(0,0,0,0.4)';
|
||||
ov.innerHTML = '<span style="font-size:2rem">🔇</span><span>Tap to enable audio</span>';
|
||||
ov.addEventListener('click', () => {
|
||||
unlockAudioContext();
|
||||
tryUnmuteLeader();
|
||||
|
|
@ -996,9 +1304,9 @@
|
|||
const tapOverlay = document.createElement('div');
|
||||
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
|
||||
tapOverlay.innerHTML = `
|
||||
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
|
||||
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
|
||||
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
|
||||
<h1 style="color:#3b82f6;font-size:3.6rem;font-family:sans-serif;margin-bottom:1.2rem">ScreenTinker</h1>
|
||||
<p style="color:#94a3b8;font-size:1.8rem;font-family:sans-serif">Tap anywhere to start</p>
|
||||
<p style="color:#64748b;font-size:1.3rem;font-family:sans-serif;margin-top:2.4rem">Audio requires user interaction</p>
|
||||
`;
|
||||
tapOverlay.onclick = () => {
|
||||
unlockAudio();
|
||||
|
|
@ -1086,6 +1394,7 @@
|
|||
// straight to the UNMODIFIED renderer. No socket, no pairing.
|
||||
async function renderPreviewFromUrl(url) {
|
||||
PREVIEW_MODE = true;
|
||||
installPreviewControlChannel(); // #239: only a preview instance can ever be steered
|
||||
config.serverUrl = window.location.origin; // same-origin -> /uploads + /api/widgets resolve
|
||||
const setup = document.getElementById('setupScreen');
|
||||
if (setup) setup.style.display = 'none';
|
||||
|
|
@ -1101,6 +1410,7 @@
|
|||
}
|
||||
renderPreviewBanner();
|
||||
handlePlaylistUpdate(payload);
|
||||
postPreviewState(); // #239: first count, in case the payload landed before the first item did
|
||||
} catch (e) {
|
||||
console.error('preview fetch failed', e);
|
||||
showPreviewError(0);
|
||||
|
|
@ -1133,6 +1443,76 @@
|
|||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
// ==================== #239 Preview transport control ====================
|
||||
// Where a next/previous press lands. Kept as a pure function (no DOM, no globals) because the
|
||||
// failure it prevents is arithmetic: a negative modulo or an out-of-range index mounts
|
||||
// `undefined` and blanks the frame, and that is only catchable by testing the maths directly.
|
||||
//
|
||||
// `allows` is the per-item schedule gate. Scanning in the DIRECTION OF TRAVEL matters: skipping
|
||||
// a dayparted item by falling forward would make "previous" walk forwards, which reads as the
|
||||
// button being broken. Returns -1 when nothing is playable (empty list, or every item outside
|
||||
// its window) so the caller can idle instead of mounting nothing.
|
||||
function previewStepIndex(current, delta, length, allows) {
|
||||
const n = Math.trunc(length);
|
||||
if (!Number.isFinite(n) || n <= 0) return -1;
|
||||
const dir = delta < 0 ? -1 : 1;
|
||||
// A player that has not started yet (currentIndex === -1) or a corrupt index still has to
|
||||
// produce a valid landing spot rather than propagate NaN into playlist[].
|
||||
const from = Number.isFinite(current) ? ((Math.trunc(current) % n) + n) % n : 0;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const idx = (((from + dir * i) % n) + n) % n;
|
||||
if (!allows || allows(idx)) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Jump the preview by one item. PREVIEW_MODE is the hard gate: a live player never installs the
|
||||
// message listener below AND would refuse here anyway, so a page that frames the player can
|
||||
// never steer content on a real screen.
|
||||
function previewNavigate(delta) {
|
||||
if (!PREVIEW_MODE) return;
|
||||
const idx = previewStepIndex(currentIndex, delta, playlist.length, (i) => scheduleAllows(playlist[i]));
|
||||
if (idx === -1) return;
|
||||
clearTimeout(scheduleRetryTimer); // we are leaving the idle screen by hand
|
||||
currentIndex = idx;
|
||||
isPlaying = true;
|
||||
playCurrentItem(); // re-renders, which clears the pending advance timer
|
||||
}
|
||||
|
||||
// Tell the dashboard which item is on screen so it can show "3 of 7". targetOrigin is our own
|
||||
// origin, so a third-party page that iframes the player learns nothing about the workspace's
|
||||
// content from this channel.
|
||||
function postPreviewState() {
|
||||
if (!PREVIEW_MODE || window.parent === window) return;
|
||||
const item = playlist[currentIndex];
|
||||
// A multi-zone playlist plays every zone at once on independent timers — there is no single
|
||||
// "current item" to step through, so say so and let the dashboard hide the controls rather
|
||||
// than offer a button that does nothing visible.
|
||||
const zoned = !!(layout && layout.zones && layout.zones.length > 1);
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
source: 'screentinker-player',
|
||||
type: 'preview:state',
|
||||
index: currentIndex,
|
||||
total: playlist.length,
|
||||
zoned,
|
||||
name: (item && (item.filename || item.widget_name || item.title)) || null,
|
||||
}, window.location.origin);
|
||||
} catch (e) { /* parent went away mid-preview */ }
|
||||
}
|
||||
|
||||
function installPreviewControlChannel() {
|
||||
window.addEventListener('message', (ev) => {
|
||||
if (!PREVIEW_MODE) return; // belt and braces: see previewNavigate
|
||||
if (ev.origin !== window.location.origin) return; // only our own dashboard may drive us
|
||||
const d = ev.data;
|
||||
if (!d || d.source !== 'screentinker-preview') return;
|
||||
if (d.action === 'next') previewNavigate(1);
|
||||
else if (d.action === 'prev') previewNavigate(-1);
|
||||
else if (d.action === 'sync') postPreviewState(); // parent (re)attached and wants the count
|
||||
});
|
||||
}
|
||||
|
||||
// #104: the always-visible honest note for webpage widgets. No auto-detection —
|
||||
// an XFO-refused frame is provably indistinguishable client-side from a working
|
||||
// one, so we never guess; we just tell the truth. Preview-only (never on device).
|
||||
|
|
@ -1376,6 +1756,9 @@
|
|||
startWatchdog(); // v4: arm-gated half-open watchdog (no-op until a heartbeat-ack arms it)
|
||||
startPlaylistRefresh();
|
||||
startVersionCheck();
|
||||
// After the socket is up, because these forward to the SERVER — wiring them earlier would
|
||||
// drop the host's boot report on the floor rather than delivering it late.
|
||||
wireHostDiagnostics();
|
||||
});
|
||||
|
||||
socket.on('device:paired', (data) => {
|
||||
|
|
@ -1567,12 +1950,17 @@
|
|||
if (BS && BS.reboot()) console.log('[bs] reboot requested via host');
|
||||
else console.log('reboot: not supported on this player');
|
||||
}
|
||||
// Media volume, 0-100 from the dashboard. Applies to whatever is playing now and is
|
||||
// remembered for items mounted later.
|
||||
// Media volume. Applies to whatever is playing now and is remembered for items mounted
|
||||
// later (see setMediaVolume). The wire parsing is volumeLevelFromCommand().
|
||||
if (data.type === 'set_volume') {
|
||||
const pct = Number(data.payload?.value ?? data.value);
|
||||
if (isFinite(pct)) setMediaVolume(Math.max(0, Math.min(100, pct)) / 100);
|
||||
const v = volumeLevelFromCommand(data);
|
||||
if (v === null) console.warn('[volume] set_volume with no usable level/value:', JSON.stringify(data));
|
||||
else setMediaVolume(v);
|
||||
}
|
||||
// The dashboard's "Debug logging" checkbox. Accepts the flag at either depth: the command
|
||||
// relay wraps it in `payload`, but the queued-command replay path and the public API have
|
||||
// both been seen to deliver it flat.
|
||||
if (data.type === 'set_debug') setRemoteDebug(!!(data.payload?.enabled ?? data.enabled));
|
||||
});
|
||||
|
||||
// #129: real-time mute. Apply immediately if the toggled item is the one playing now;
|
||||
|
|
@ -1723,11 +2111,21 @@
|
|||
}
|
||||
} catch (e) { /* bundle absent: hard cuts, and we do not claim the capability */ }
|
||||
|
||||
// Offline caching is the service worker. Reported on support rather than on an active
|
||||
// controller: the first load registers it and has no controller yet, and a display that
|
||||
// re-registers on every boot would otherwise flap this capability on and off.
|
||||
// Offline caching is the service worker — and the API EXISTING is not the same as it
|
||||
// working. A real BrightSign XT245 on alpha has `serviceWorker` in navigator, passes this
|
||||
// check, and then never even fetches sw.js: registration is refused by its widget runtime.
|
||||
// It declared offline.cache to the fleet and could not cache a single byte.
|
||||
//
|
||||
// So the claim is made on a worker that is actually IN CONTROL. The cost is that the very
|
||||
// first load under-reports (registration has happened but the worker has not claimed the
|
||||
// page yet) — which is the right direction to be wrong in, and self-corrects: activation
|
||||
// triggers a reload, and the next register sends the true set. `swRegistrationFailed` makes
|
||||
// the negative stick on a runtime where it will never succeed, rather than waiting on a
|
||||
// controller that is never coming.
|
||||
try {
|
||||
if ('serviceWorker' in navigator) caps.push('offline.cache');
|
||||
if (!swRegistrationFailed && navigator.serviceWorker && navigator.serviceWorker.controller) {
|
||||
caps.push('offline.cache');
|
||||
}
|
||||
} catch (e) { /* locked-down browser */ }
|
||||
|
||||
// Screenshots need somewhere to draw. Same-origin content and a 2d context are the real
|
||||
|
|
@ -2204,24 +2602,36 @@
|
|||
// the stage — which keeps the vertical position of every source pixel
|
||||
// identical across devices that share a viewport height (1vh maps to
|
||||
// the same physical pixel on each).
|
||||
// #236: the geometry (including per-panel mounting rotation) comes from the shared rule in
|
||||
// server/lib/wall-geometry.js. Kept out of here because Tizen, Android and this player must
|
||||
// agree to the pixel across a seam, and because the rotated cases are only checkable by test.
|
||||
function styleWallStage(stageEl) {
|
||||
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
|
||||
const s = wallConfig.screen_rect;
|
||||
const p = wallConfig.player_rect;
|
||||
if (!s.w || !s.h) return;
|
||||
const left = ((p.x - s.x) / s.w) * 100;
|
||||
const top = ((p.y - s.y) / s.h) * 100;
|
||||
const width = (p.w / s.w) * 100;
|
||||
const height = (p.h / s.h) * 100;
|
||||
const st = window.WallGeometry
|
||||
? window.WallGeometry.wallStageStyle(s, p, wallConfig.rotation)
|
||||
// If the shared script failed to load, fall back to the unrotated rule rather than leaving
|
||||
// the stage unstyled — a wrong-but-full-frame panel beats a blank one on a live wall.
|
||||
: {
|
||||
left: (((p.x - s.x) / s.w) * 100) + 'vw',
|
||||
top: (((p.y - s.y) / s.h) * 100) + 'vh',
|
||||
width: ((p.w / s.w) * 100) + 'vw',
|
||||
height: ((p.h / s.h) * 100) + 'vh',
|
||||
transform: '', transformOrigin: '',
|
||||
};
|
||||
if (!st) return;
|
||||
const dev = (config.deviceId || '?').slice(0, 8);
|
||||
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
|
||||
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p) + ' rotation=' + (wallConfig.rotation || 0));
|
||||
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
|
||||
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
|
||||
stageEl.style.left = left + 'vw';
|
||||
stageEl.style.top = top + 'vh';
|
||||
stageEl.style.width = width + 'vw';
|
||||
stageEl.style.height = height + 'vh';
|
||||
stageEl.style.transform = '';
|
||||
console.log('[wall/render ' + dev + '] stage: left=' + st.left + ' top=' + st.top + ' width=' + st.width + ' height=' + st.height + ' transform=' + (st.transform || 'none'));
|
||||
stageEl.style.left = st.left;
|
||||
stageEl.style.top = st.top;
|
||||
stageEl.style.width = st.width;
|
||||
stageEl.style.height = st.height;
|
||||
stageEl.style.transform = st.transform;
|
||||
stageEl.style.transformOrigin = st.transformOrigin;
|
||||
}
|
||||
|
||||
// No-op kept for callers that bind a resize listener (kept around in case
|
||||
|
|
@ -2266,6 +2676,12 @@
|
|||
|
||||
// Apply orientation. #109: the PiP layer gets the SAME transform as the player so a
|
||||
// corner overlay tracks the visible content (not the physical panel) in every orientation.
|
||||
// #236: on a wall panel that carries its own mounting rotation, the two settings describe the
|
||||
// SAME physical fact, so honouring both turns the content twice and throws it off-screen. The
|
||||
// wall rotation wins there; when it is 0 (every wall in the field today) nothing changes.
|
||||
const effectiveOrientation = window.WallGeometry
|
||||
? window.WallGeometry.orientationForWallMember(data.orientation, data.wall_config?.rotation)
|
||||
: data.orientation;
|
||||
if (data.orientation) {
|
||||
// On BrightSign, rotate the OUTPUT first. Video decodes onto a hardware plane the DOM
|
||||
// cannot transform, so the CSS rotation below turns the images and widgets and leaves the
|
||||
|
|
@ -2276,7 +2692,7 @@
|
|||
// cleared or the graphics rotate twice. If it cannot, we fall through to CSS, which rotates
|
||||
// most of the content rather than none of it.
|
||||
if (BS && typeof BS.setOrientation === 'function' && BS.hasHost()) {
|
||||
BS.setOrientation(data.orientation).then((rotatedByHost) => {
|
||||
BS.setOrientation(effectiveOrientation).then((rotatedByHost) => {
|
||||
if (!rotatedByHost) return; // CSS path below already ran
|
||||
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
|
||||
.forEach((el) => {
|
||||
|
|
@ -2293,7 +2709,7 @@
|
|||
// portrait content landed 420px off-screen on a 1920x1080 panel — rotated correctly and
|
||||
// placed wrongly. Tizen and Android both centre the box first; the web player did not.
|
||||
const st = window.OrientationStyle
|
||||
? window.OrientationStyle.orientationStyle(data.orientation)
|
||||
? window.OrientationStyle.orientationStyle(effectiveOrientation)
|
||||
: null;
|
||||
if (st) {
|
||||
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
|
||||
|
|
@ -2315,7 +2731,10 @@
|
|||
function wallKey(c) {
|
||||
if (!c) return '';
|
||||
const s = c.screen_rect || {}, p = c.player_rect || {};
|
||||
return `${c.wall_id}:${c.is_leader}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
|
||||
// #236: rotation is part of the key. Left out, re-hanging a panel and changing only its
|
||||
// rotation in the editor looks like "no change" and the panel keeps the old transform until
|
||||
// it is rebooted — which reads as the editor silently ignoring you.
|
||||
return `${c.wall_id}:${c.is_leader}:r${c.rotation || 0}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
|
||||
}
|
||||
const wallChanged = wallKey(wallConfig) !== wallKey(data.wall_config);
|
||||
if (wallChanged) applyWallMode(data.wall_config || null);
|
||||
|
|
@ -2571,6 +2990,10 @@
|
|||
|
||||
renderContent(item);
|
||||
|
||||
// #239: the dashboard's "3 of 7" follows the player's own advance, not just operator presses,
|
||||
// so it stays honest when an item ends on its own duration.
|
||||
if (PREVIEW_MODE) postPreviewState();
|
||||
|
||||
// Push an immediate sync so followers don't have to wait up to 1s for
|
||||
// the next periodic tick before snapping to the new item.
|
||||
if (wallConfig?.is_leader) emitWallSync();
|
||||
|
|
@ -2868,6 +3291,12 @@
|
|||
pendingWidgetSwap = null;
|
||||
}
|
||||
|
||||
function widgetSandboxAttr(item) {
|
||||
return item && item.widget_allow_same_origin
|
||||
? 'allow-scripts allow-same-origin'
|
||||
: 'allow-scripts';
|
||||
}
|
||||
|
||||
// Buffered widget render (#directory-board black-cycle): build the new widget iframe
|
||||
// BEHIND the current content (hidden) and reveal it only once it fires 'load' — then tear
|
||||
// down the outgoing content. Kills the black flash on every widget transition, and lets a
|
||||
|
|
@ -2888,7 +3317,7 @@
|
|||
iframe.style.visibility = 'hidden';
|
||||
iframe.allow = 'autoplay; fullscreen';
|
||||
// Sandbox into a unique origin so widget scripts can't read window.parent state.
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
iframe.setAttribute('sandbox', widgetSandboxAttr(item));
|
||||
|
||||
const reveal = () => {
|
||||
if (!pendingWidgetSwap || pendingWidgetSwap.iframe !== iframe) return; // superseded / discarded
|
||||
|
|
@ -2914,7 +3343,7 @@
|
|||
// schedule-awareness / Fix A preserved).
|
||||
function reevaluateHeldWidget() {
|
||||
if (nextActiveIndex(currentIndex) === currentIndex) {
|
||||
advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
|
||||
scheduleAdvance(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
|
||||
return;
|
||||
}
|
||||
nextItem();
|
||||
|
|
@ -2974,7 +3403,7 @@
|
|||
const c = document.getElementById('playerContainer');
|
||||
c.style.display = 'block';
|
||||
c.appendChild(img);
|
||||
advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
|
||||
scheduleAdvance(nextItem, (item.duration_sec || 10) * 1000);
|
||||
preloadNextImage();
|
||||
}
|
||||
// ---- GL transition (feat/transition-engine). Every failure path hard-cuts — never a blank. ----
|
||||
|
|
@ -2985,12 +3414,38 @@
|
|||
// the frame on screen now, as a texturable (CORS-clean) source: the live <img>, or — so a wipe can
|
||||
// start FROM a playing clip — a snapshot canvas of the outgoing <video>'s current frame. Returns null
|
||||
// if nothing on screen is texturable yet (first item after boot, un-decoded, or tainted) -> hard cut.
|
||||
// Does a <video> on THIS platform actually yield pixels to a canvas?
|
||||
//
|
||||
// On a hardware video plane (BrightSign hwz, Tizen AVPlay) the answer is no, and the failure is
|
||||
// silent: drawImage() succeeds, throws nothing, and paints a fully TRANSPARENT frame. The
|
||||
// transition then runs with a blank `from` or `to` texture — a wipe from nothing, behind a video
|
||||
// plane that is still lit. isMediaReadable() cannot catch it: it answers "am I ALLOWED to read
|
||||
// this" (CORS), which is a different question from "did any pixels arrive".
|
||||
//
|
||||
// videoFrameIsCapturable() already asks the right question (a 16x16 ALPHA probe, so a genuine
|
||||
// fade-to-black still reads as captured) but was only ever wired into the screenshot path.
|
||||
// Cached because the answer is a property of the platform, not of the clip.
|
||||
// (The cache variable itself is declared far above, in State — see the note there. It MUST NOT
|
||||
// be declared here: boot renders the cached playlist from a call site above this line, and a
|
||||
// `let` read before its declaration executes is a TemporalDeadZone throw, not a `null`.)
|
||||
function videoCompositingAvailable(v) {
|
||||
if (_videoCompositingOk !== null) return _videoCompositingOk;
|
||||
if (!v || v.readyState < 2 || !v.videoWidth) return true; // undecided — don't cache a guess
|
||||
_videoCompositingOk = videoFrameIsCapturable(v);
|
||||
if (!_videoCompositingOk) {
|
||||
console.log('[transition] video frames are not readable on this platform (hardware plane) — ' +
|
||||
'transitions involving video will hard-cut; image-to-image still wipes');
|
||||
}
|
||||
return _videoCompositingOk;
|
||||
}
|
||||
|
||||
function currentTexturableFrame() {
|
||||
const c = document.getElementById('playerContainer');
|
||||
if (!c) return null;
|
||||
const img = c.querySelector('img');
|
||||
if (img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) return img;
|
||||
const v = c.querySelector('video');
|
||||
if (v && !videoCompositingAvailable(v)) return null; // hardware plane -> no from-frame -> hard cut
|
||||
if (v && v.readyState >= 2 && v.videoWidth > 0 && isMediaReadable(v)) {
|
||||
try {
|
||||
const r = c.getBoundingClientRect();
|
||||
|
|
@ -3094,7 +3549,7 @@
|
|||
const dwellMs = (item.duration_sec || 10) * 1000;
|
||||
const container = document.getElementById('playerContainer');
|
||||
runGlWipe(fromImg, toImg, t, dwellMs,
|
||||
() => { advanceTimer = setTimeout(nextItem, dwellMs); }, // onStart: arm the dwell for overlap
|
||||
() => { scheduleAdvance(nextItem, dwellMs); }, // onStart: arm the dwell for overlap
|
||||
() => { // mount: swap in the image, keep the armed timer
|
||||
toImg.style.cssText = 'width:100%;height:100%;object-fit:contain';
|
||||
container.appendChild(toImg);
|
||||
|
|
@ -3124,7 +3579,9 @@
|
|||
const fail = () => {
|
||||
if (done) return; done = true;
|
||||
if (watchdog) clearTimeout(watchdog);
|
||||
console.error('Image error'); advanceTimer = setTimeout(nextItem, 3000); // skip broken item; hold prior frame
|
||||
// No element to inspect here: this is the load/watchdog failure path, so nothing decoded.
|
||||
// `fail` is already `done`-guarded, so it runs at most once per render.
|
||||
mediaFailureSkip(null, 'image', src); // skip broken item; hold prior frame
|
||||
};
|
||||
// a hung load/decode must never stall the playlist: at 3s use what we have, else skip
|
||||
watchdog = setTimeout(() => { if (cached) swap(cached); else fail(); }, 3000);
|
||||
|
|
@ -3193,7 +3650,7 @@
|
|||
video.muted = (!userHasInteracted || !!item.muted); // autoplay policy + per-item mute (#129)
|
||||
if (!video.muted) video.volume = 1.0;
|
||||
video.onended = () => { if (!video.loop) nextItem(); };
|
||||
video.onerror = (e) => { console.error('Video error:', src, e); advanceTimer = setTimeout(nextItem, 3000); };
|
||||
video.onerror = () => mediaFailureSkip(video, 'video', src);
|
||||
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); }); // autoplay-policy fallback
|
||||
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000); // last-resort kick
|
||||
};
|
||||
|
|
@ -3231,7 +3688,7 @@
|
|||
video.addEventListener('error', () => {
|
||||
if (done) return; done = true;
|
||||
if (watchdog) clearTimeout(watchdog);
|
||||
console.error('Video error:', src); advanceTimer = setTimeout(nextItem, 3000); // skip broken clip; hold prior frame
|
||||
mediaFailureSkip(video, 'video(buffered)', src); // skip broken clip; hold prior frame
|
||||
});
|
||||
// Watchdog caps the wait so a cold/slow clip hard-cuts (mount+play) instead of the boundary hanging
|
||||
// (mirrors renderImageBuffered's watchdog).
|
||||
|
|
@ -3248,7 +3705,7 @@
|
|||
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
|
||||
// Defense in depth: a transition widget is normalized out server-side and must never render as
|
||||
// content. If a stale/legacy payload still carries one, skip it instead of mounting a blank iframe.
|
||||
if (item && item.widget_type === 'transition') { advanceTimer = setTimeout(nextItem, 0); return; }
|
||||
if (item && item.widget_type === 'transition') { scheduleAdvance(nextItem, 0); return; }
|
||||
// Fullscreen (non-wall) widget: buffered swap — never blank on reload. Runs BEFORE the
|
||||
// generic teardown (which would black the screen), and owns its own refresh/advance
|
||||
// timer below. Multi-zone widgets go through renderZones; wall+widget keeps the legacy
|
||||
|
|
@ -3265,8 +3722,8 @@
|
|||
// its duration; the first mount + every genuine transition still go through the
|
||||
// buffered swap.
|
||||
const held = nextActiveIndex(currentIndex) === currentIndex;
|
||||
if (held) advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
|
||||
else advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
|
||||
if (held) scheduleAdvance(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
|
||||
else scheduleAdvance(nextItem, (item.duration_sec || 30) * 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -3289,7 +3746,12 @@
|
|||
&& item.mime_type.startsWith('video/') && item.mime_type !== 'video/youtube'
|
||||
&& !item.widget_id && !wallConfig && !isZones && !groupSync
|
||||
&& item.transition && Array.isArray(item.transition.effects) && item.transition.effects.length
|
||||
&& transitionRuntimeReady();
|
||||
&& transitionRuntimeReady()
|
||||
// A platform whose video sits on a hardware plane cannot supply the incoming frame either:
|
||||
// the warm-play snapshot comes back transparent, so the wipe would fade in from nothing.
|
||||
// `null` means "not yet determined" and is treated as available — the probe needs a
|
||||
// playing video, and the first one on a fresh player has not run yet.
|
||||
&& _videoCompositingOk !== false;
|
||||
if (isVideoBufferable) {
|
||||
renderVideoBuffered(item);
|
||||
return;
|
||||
|
|
@ -3386,10 +3848,7 @@
|
|||
// advances the index (and the tick seeks position % duration to stay aligned).
|
||||
video.loop = (playlist.length === 1) || !!groupSync;
|
||||
video.onended = () => { if (!video.loop && !isFollower) nextItem(); };
|
||||
video.onerror = (e) => {
|
||||
console.error('Video error:', src, e);
|
||||
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
|
||||
};
|
||||
video.onerror = () => mediaFailureSkip(video, 'video', src, !isFollower);
|
||||
video.onloadeddata = () => {
|
||||
console.log('[wall/audio] video loaded file=' + item.filename + ' role=' + (wallConfig ? (wallConfig.is_leader ? 'leader' : 'follower') : 'solo') + ' muted=' + video.muted + ' volume=' + video.volume);
|
||||
};
|
||||
|
|
@ -3423,13 +3882,10 @@
|
|||
img.style.cssText = wallConfig
|
||||
? 'width:100%;height:100%;object-fit:fill'
|
||||
: 'width:100%;height:100%;object-fit:contain';
|
||||
img.onerror = () => {
|
||||
console.error('Image error');
|
||||
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
|
||||
};
|
||||
img.onerror = () => mediaFailureSkip(img, 'image', src, !isFollower);
|
||||
mount.appendChild(img);
|
||||
// Leader / single screen drives image advance; follower waits for sync
|
||||
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
|
||||
if (!isFollower) scheduleAdvance(nextItem, (item.duration_sec || 10) * 1000);
|
||||
} else if (item.widget_id) {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
|
||||
|
|
@ -3437,10 +3893,10 @@
|
|||
iframe.allow = 'autoplay; fullscreen';
|
||||
// Sandbox into a unique origin so widget scripts can't read window.parent
|
||||
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
iframe.setAttribute('sandbox', widgetSandboxAttr(item));
|
||||
mount.appendChild(iframe);
|
||||
if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(mount); // #104
|
||||
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
|
||||
if (!isFollower) scheduleAdvance(nextItem, (item.duration_sec || 30) * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3549,7 +4005,7 @@
|
|||
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${a.widget_rev||0}`;
|
||||
// Sandbox into a unique origin so widget scripts can't read window.parent
|
||||
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
iframe.setAttribute('sandbox', widgetSandboxAttr(a));
|
||||
div.appendChild(iframe);
|
||||
if (PREVIEW_MODE && a.widget_type === 'webpage') addWebpageNote(div); // #104
|
||||
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
|
||||
|
|
@ -3805,6 +4261,34 @@
|
|||
// Falls through to the canvas path on ANY failure (no host, no primary storage, timeout):
|
||||
// a partial screenshot showing images and widgets beats none, and the probe makes the
|
||||
// video's absence explicit rather than silent.
|
||||
// BrightSign's OWN capture API first: it composites the video and graphics layers, which is
|
||||
// the one thing this player cannot do for itself — a canvas cannot read the hardware video
|
||||
// plane, so a DOM composite comes back with the content missing. It needs no host bridge at
|
||||
// all, which is what makes it work where the host route does not: page->host messaging is
|
||||
// dead after load on that platform, so a request relayed through BrightScript never arrives.
|
||||
if (BS && typeof BS.captureScreen === 'function') {
|
||||
BS.captureScreen({ width: 960, height: 540 })
|
||||
.then((dataUrl) => {
|
||||
const base64 = String(dataUrl).split(',')[1];
|
||||
if (base64 && base64.length > 100) {
|
||||
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
|
||||
console.log('[bs] native screenshot sent:', base64.length, 'chars');
|
||||
} else { captureAndSendCanvas(); }
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[bs] native capture unavailable (' + err.message + ') — trying the host');
|
||||
hostSnapshotOrCanvas();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
hostSnapshotOrCanvas();
|
||||
}
|
||||
|
||||
// The older route: ask the HOST to capture through the player's DWS. Kept as a fallback for
|
||||
// firmware where the native module is absent, though on the hardware this was debugged against
|
||||
// the request never reaches the host at all.
|
||||
function hostSnapshotOrCanvas() {
|
||||
if (BS && typeof BS.requestSnapshot === 'function' && BS.hasHost()) {
|
||||
BS.requestSnapshot({ width: 960, height: 540 })
|
||||
.then((dataUrl) => {
|
||||
|
|
@ -3888,6 +4372,42 @@
|
|||
try { return BS.displayPower(on); } catch (e) { return false; }
|
||||
}
|
||||
|
||||
/*
|
||||
* The 0..1 volume a set_volume command is asking for, or null when it does not carry one.
|
||||
*
|
||||
* The wire form is `payload.level`, a FRACTION. That is what the dashboard sends
|
||||
* (device-detail.js: `{ level: slider / 100 }`), what a group command relays unchanged, and what
|
||||
* the Android player reads (`optDouble("level")`) — so the fraction is canonical and this player
|
||||
* now matches it instead of inventing a third convention.
|
||||
*
|
||||
* What was here read `payload.value` and treated it as a percentage, which matched nothing the
|
||||
* product sends: the slider was a no-op on every browser panel. Correcting only the KEY would
|
||||
* have been worse than leaving it broken — 0.5 clamped as a percentage is 0.5%, inaudible, and
|
||||
* it would have looked fixed to anyone who checked only that the handler ran.
|
||||
*
|
||||
* `value` is still accepted as a 0..100 percentage for any caller written against the old
|
||||
* handler. The scale is chosen by WHICH KEY ARRIVED, never by the magnitude of the number: 1 is
|
||||
* legal in both scales (1% and full volume), so a magnitude test is guaranteed to be wrong for
|
||||
* somebody, silently, in whichever direction hurts more.
|
||||
*
|
||||
* Extracted from the socket handler so the parsing can be asserted without a socket — the bug
|
||||
* it replaces was invisible precisely because nothing ever ran it against a real payload.
|
||||
*/
|
||||
function volumeLevelFromCommand(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const p = (data.payload && typeof data.payload === 'object') ? data.payload : {};
|
||||
const pick = (k) => {
|
||||
const raw = (p[k] !== undefined && p[k] !== null) ? p[k] : data[k];
|
||||
if (raw === undefined || raw === null || raw === '' || typeof raw === 'boolean') return null;
|
||||
const n = Number(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
};
|
||||
const level = pick('level'); // canonical: 0..1
|
||||
const pct = level === null ? pick('value') : null; // legacy: 0..100
|
||||
const v = level !== null ? level : (pct !== null ? pct / 100 : null);
|
||||
return v === null ? null : Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
// Volume that survives the next item. Media elements are created per item, so remembering the
|
||||
// level is what makes a volume command stick rather than lasting until the playlist advances.
|
||||
let mediaVolume = null;
|
||||
|
|
@ -3896,8 +4416,17 @@
|
|||
// Wall followers stay silent by design; don't override that.
|
||||
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (e) { /* not a wall */ }
|
||||
document.querySelectorAll('video, audio').forEach((el) => {
|
||||
try { el.volume = v; el.muted = v === 0; } catch (e) { /* element torn down mid-call */ }
|
||||
try { el.volume = v; } catch (e) { /* element torn down mid-call */ }
|
||||
});
|
||||
// Volume is a LEVEL. Mute is a separate decision with four inputs and a fixed order
|
||||
// (lib/media-mute.js), already resolved when the element was mounted, and this function does
|
||||
// not get to overrule it. It used to write `el.muted = (v === 0)`, which un-muted whatever was
|
||||
// playing on any non-zero volume: an item an operator had deliberately silenced started making
|
||||
// noise the moment somebody touched the volume slider — reproduced live (item flagged muted,
|
||||
// one set_volume, muted went false). The same write contradicts the resolver's autoplay rule,
|
||||
// where unmuting without a user gesture costs the VIDEO rather than winning the audio.
|
||||
//
|
||||
// Nothing is lost by leaving mute alone: volume 0 is silence on every media element we run on.
|
||||
}
|
||||
|
||||
// Media elements are created per item across several code paths — fullscreen, zones, the
|
||||
|
|
@ -3915,7 +4444,10 @@
|
|||
}
|
||||
if (mediaVolume == null) return;
|
||||
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (err) { /* not a wall */ }
|
||||
try { el.volume = mediaVolume; el.muted = mediaVolume === 0; } catch (err) { /* gone */ }
|
||||
// Level only — see setMediaVolume: the mount path has already resolved this element's mute
|
||||
// from media-mute.js, and re-deciding it here from the volume number alone would undo a
|
||||
// per-item mute (and, without a user gesture, the playback itself).
|
||||
try { el.volume = mediaVolume; } catch (err) { /* gone */ }
|
||||
}, true);
|
||||
|
||||
// Screen-off state, tracked because the playlist keeps advancing while the screen is "off"
|
||||
|
|
@ -4057,7 +4589,12 @@
|
|||
// with. Registration succeeded there and then controlled nothing: no shell cache, no content
|
||||
// cache, no offline playback, and no error to notice. The server sends
|
||||
// Service-Worker-Allowed so this wider scope is permitted.
|
||||
navigator.serviceWorker.register('/player/sw.js', { scope: '/' }).then(reg => {
|
||||
// Registered from the ROOT, where the default scope already covers the whole origin. The
|
||||
// previous form asked for a wider-than-default scope and relied on a Service-Worker-Allowed
|
||||
// header reaching the browser — which Cloudflare withheld from a cached response across a
|
||||
// deploy, so registration failed and the player ran with no worker at all. A rejected
|
||||
// registration is worse than a narrow one, and nothing about it is visible from here.
|
||||
navigator.serviceWorker.register('/sw.js').then(reg => {
|
||||
console.log('Service Worker registered');
|
||||
// When a new SW activates, reload so the fresh code takes effect immediately
|
||||
reg.addEventListener('updatefound', () => {
|
||||
|
|
@ -4071,7 +4608,20 @@
|
|||
});
|
||||
}
|
||||
});
|
||||
}, (err) => console.warn('SW registration failed:', err));
|
||||
}, (err) => {
|
||||
// A registration that fails has to be VISIBLE. This one went to console.warn on a display
|
||||
// nobody has a console for, so a panel that could not cache anything looked identical to
|
||||
// one that could — for as long as nobody thought to compare nginx logs against the
|
||||
// capability it was advertising.
|
||||
swRegistrationFailed = true;
|
||||
console.warn('SW registration failed:', err);
|
||||
try {
|
||||
// 'app_error' rather than a new type: the server allow-lists event types, and a type it
|
||||
// does not know is dropped silently — which would have made this report as invisible as
|
||||
// the console.warn it replaces.
|
||||
emitDeviceEvent('app_error', 'sw_unavailable', String((err && err.message) || err).slice(0, 200));
|
||||
} catch (e) { /* reporting must never break the player */ }
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Keyboard shortcuts ====================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
// v24: an empty playlist payload no longer prunes. `assignments: []` is what the server sends for a
|
||||
// device between playlists AND for a snapshot that failed to parse, and treating it as "keep
|
||||
// nothing" wiped the panel's entire offline library on a message that means nothing of the sort.
|
||||
// v23: offline.cache is claimed only when a worker is actually IN CONTROL — a real BrightSign
|
||||
// widget exposes navigator.serviceWorker, refuses to register one, and was advertising the
|
||||
// capability to the fleet regardless.
|
||||
// v22: worker scope widened to '/' (it never controlled /player before) + prune-to-playlist, so a
|
||||
// replaced asset's superseded copy is reclaimed rather than waiting on the quota.
|
||||
// v21: chunked resumable content prefetch + revision-keyed media URLs — index.html gained
|
||||
|
|
@ -8,7 +14,7 @@
|
|||
// — a player then ran a new index.html against a stale st-bridge.js and threw on every heartbeat.
|
||||
// Bump whenever a shipped /player asset changes shape; content lives in its own cache, so this
|
||||
// costs a small re-download and never re-fetches the playlist.
|
||||
const CACHE_NAME = 'rd-player-v22';
|
||||
const CACHE_NAME = 'rd-player-v24';
|
||||
// Content lives in its own cache so the shell can be re-versioned (the activate handler deletes
|
||||
// every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still
|
||||
// perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist.
|
||||
|
|
@ -48,11 +54,26 @@ self.addEventListener('fetch', (event) => {
|
|||
const url = new URL(event.request.url);
|
||||
|
||||
// Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change
|
||||
// without the rev changing. This is what lets a widget keep rendering when the network is gone —
|
||||
// previously the server sent no-store for every render, so widgets were the one thing the
|
||||
// player's offline cache could never hold, and a display that lost its uplink lost them.
|
||||
// ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it
|
||||
// would match a different revision's entry, which is the staleness we are trying to remove.
|
||||
// without the rev changing. ignoreSearch is deliberately NOT used here: the query string carries
|
||||
// the rev, and ignoring it would match a different revision's entry, which is the staleness we
|
||||
// are trying to remove.
|
||||
//
|
||||
// MEASURED LIMIT, do not read more into this branch than it delivers. The player mounts widgets in
|
||||
// an iframe sandboxed to `allow-scripts` with NO allow-same-origin (index.html,
|
||||
// renderWidgetBuffered), so that frame is an OPAQUE-origin client — and a service worker does not
|
||||
// control opaque-origin clients. Its navigation request never reaches this handler. Driven in
|
||||
// Chrome against a live server: a clock widget mounted five times over 25s and the shell cache
|
||||
// held zero widget entries, while a plain fetch() of the identical URL from the controlled page
|
||||
// was intercepted and stored. So this branch serves anything that reaches it — a same-origin
|
||||
// fetch, a future non-sandboxed mount — and today the player's own widgets are not that.
|
||||
//
|
||||
// What actually keeps widgets rendering offline right now is the HTTP cache plus the server's
|
||||
// `max-age=31536000, immutable` on a rev-pinned render (routes/widgets.js). That is sound in a
|
||||
// desktop browser and is NOT a documented-persistent store on BrightSign, which guarantees
|
||||
// survival across reboots for IndexedDB, localStorage and SQLite only — the same gap that made
|
||||
// content caching necessary. Closing it properly means routing the render through a same-origin
|
||||
// fetch and mounting it as srcdoc; granting the frame allow-same-origin instead would hand widget
|
||||
// scripts the player's origin, which is not a trade worth making for an offline nicety.
|
||||
if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
|
|
@ -140,7 +161,19 @@ self.addEventListener('message', (event) => {
|
|||
// writes a new randomly-named file, so the old copy lives at a different PATH and nothing keyed
|
||||
// on the asset path can find it. Without this the cache only grows, and on a panel with a 1GB
|
||||
// widget quota a handful of replaced videos is the entire budget.
|
||||
if (data.prune) prefetchChain = prefetchChain.then(() => pruneToPlaylist(data.urls)).catch(() => {});
|
||||
//
|
||||
// An EMPTY list is never a prune instruction, and that distinction is the whole guard. "This
|
||||
// display needs nothing" and "the payload did not arrive intact" are the same message on the
|
||||
// wire, and the second one is not rare: buildPlaylistPayload() yields `assignments: []` for a
|
||||
// device between playlists, for a playlist that has never been published, AND for a
|
||||
// published_snapshot that fails to JSON.parse. Honouring it deleted every byte of media the panel
|
||||
// held — reproduced here: three cached assets, one empty payload, cache emptied — which is only
|
||||
// survivable while the uplink is up, i.e. exactly when this cache does not matter. A cache that is
|
||||
// kept too long costs disk the quota reclaims anyway; one dropped at the wrong moment is a dark
|
||||
// screen with no way back.
|
||||
if (data.prune && data.urls.length > 0) {
|
||||
prefetchChain = prefetchChain.then(() => pruneToPlaylist(data.urls)).catch(() => {});
|
||||
}
|
||||
|
||||
for (const url of data.urls) {
|
||||
if (typeof url !== 'string' || !POLICY || !POLICY.isCacheableContent(url, 'GET')) continue;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
|||
const bcrypt = require('bcryptjs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db } = require('../db/database');
|
||||
const oidcProviders = require('../lib/oidc-providers');
|
||||
const { canAdminWorkspace } = require('../lib/permissions');
|
||||
const { requirePlatformAdmin, requireAdmin } = require('../middleware/auth');
|
||||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
|
|
@ -20,7 +21,9 @@ const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require(
|
|||
// have no user/role-management power (#13).
|
||||
|
||||
// Same email shape the invite-create endpoint validates against (workspaces.js).
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
// Markup characters are not legal here. The looser form admitted < > " ' and an admin-
|
||||
// chosen email became stored XSS in the platform admin's user list.
|
||||
const EMAIL_RE = /^[^\s@<>"'`\\;,()\[\]]+@[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/;
|
||||
const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer'];
|
||||
// Mirror the server-side minimum enforced by PUT /api/auth/me and register.
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
|
@ -55,6 +58,53 @@ router.post('/users', (req, res) => {
|
|||
if (!canAdminWorkspace(db, req.user, ws)) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
/*
|
||||
* ⚠️ An SSO-only organization must not have password accounts minted into it.
|
||||
*
|
||||
* This route creates a LOCAL account with an admin-chosen password, and it accepts any address —
|
||||
* so on a tenant that requires single sign-on it was a one-call backdoor: create
|
||||
* `contractor@somewhere-else.test` bound to the workspace, log in with the password, and every
|
||||
* control the customer turned SSO-only on for is behind you. A review did exactly that, and the
|
||||
* account it created could then mint another.
|
||||
*
|
||||
* platform_admin keeps the ability, because that is the operator break-glass — the same
|
||||
* exemption the login gate makes, for the same reason.
|
||||
*/
|
||||
if (req.user.role !== 'platform_admin' && ws.organization_id) {
|
||||
// The table is absent on a single-tenant install; that simply means no organization requires
|
||||
// single sign-on, so creation proceeds.
|
||||
let org = null;
|
||||
try { org = db.prepare('SELECT sso_only, name FROM organizations WHERE id = ?').get(ws.organization_id); }
|
||||
catch { org = null; }
|
||||
if (org && org.sso_only) {
|
||||
return res.status(400).json({
|
||||
error: `${org.name || 'This organization'} requires single sign-on, so password accounts cannot be created. Invite the person through your identity provider instead.`,
|
||||
code: 'sso_only_org',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ⚠️ And the ADDRESS's own domain, wherever it is being created.
|
||||
*
|
||||
* Gating only on the target workspace left the squat open through a different door: create your
|
||||
* own organization, then mint `cfo@theircompany.test` into YOUR workspace. Login is refused, so
|
||||
* it is not access — but the row now has a password_hash, and an SSO login will not adopt a row
|
||||
* that has one. The real CFO can then never sign in through their own identity provider, and a
|
||||
* password reset they CAN complete lands them at a login that refuses them. Permanent, with no
|
||||
* self-service way out, for any address at any SSO-only customer.
|
||||
*/
|
||||
if (req.user.role !== 'platform_admin') {
|
||||
let ownedBy = null;
|
||||
try { ownedBy = oidcProviders.ssoOnlyForEmail(email); } catch { ownedBy = { unavailable: true }; }
|
||||
if (ownedBy) {
|
||||
return res.status(400).json({
|
||||
error: 'That email domain uses single sign-on, so a password account cannot be created for it.',
|
||||
code: 'sso_only_domain',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp the target workspace so the activityLogger middleware (and our
|
||||
// explicit audit row) attribute to the right tenant.
|
||||
req.workspaceId = ws.id;
|
||||
|
|
@ -376,6 +426,50 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
|
|||
res.json({ enabled });
|
||||
});
|
||||
|
||||
// ===================== Opt-in install statistics =====================
|
||||
// Returns the decision state, the EXACT payload that would be sent, and what was last actually
|
||||
// sent. Handing over the real payload rather than a description is the point: an operator can
|
||||
// check instead of trusting a sentence, and the code is public so a mismatch would be visible.
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
router.get('/telemetry', requirePlatformAdmin, (req, res) => {
|
||||
res.json({
|
||||
state: telemetry.state(), // 'unasked' | 'on' | 'off'
|
||||
payload: telemetry.payload(db), // what WOULD be sent, right now
|
||||
endpoint: telemetry.endpoint(), // ours — the host an operator may need to allowlist
|
||||
extra_endpoint: telemetry.extraEndpoint(),// their own collector, if configured
|
||||
destinations: telemetry.destinations(), // everywhere it actually goes, right now
|
||||
last_report: telemetry.getLastReport(), // what was actually sent, and when
|
||||
last_error: telemetry.getLastError(), // why the last attempt failed, if it did
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/telemetry', requirePlatformAdmin, async (req, res) => {
|
||||
// Both answers are recorded. Declining must persist as 'off' rather than staying 'unasked',
|
||||
// or the prompt returns after every update — which is how telemetry earns its bad name.
|
||||
const enabled = !!req.body.enabled;
|
||||
const state = telemetry.setEnabled(enabled);
|
||||
logActivity(req.user.id, 'admin_set_telemetry', `enabled: ${enabled}`, null, getClientIp(req), null);
|
||||
|
||||
// Send once, now, rather than waiting for the next daily tick. Two reasons: the operator is
|
||||
// standing right here and "nothing has been sent" for the next 24h reads as broken, and an
|
||||
// egress-filtered network fails HERE where we can name the host to unblock — instead of
|
||||
// failing silently tonight where nobody is watching.
|
||||
let first = null;
|
||||
if (enabled) first = await telemetry.report(db);
|
||||
|
||||
res.json({
|
||||
state,
|
||||
payload: telemetry.payload(db),
|
||||
endpoint: telemetry.endpoint(),
|
||||
extra_endpoint: telemetry.extraEndpoint(),
|
||||
destinations: telemetry.destinations(),
|
||||
first_report: first && { sent: first.sent, reason: first.reason || null },
|
||||
last_report: telemetry.getLastReport(),
|
||||
last_error: telemetry.getLastError(),
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== Version update indicator =====================
|
||||
// check-update = requireAdmin — a read-only GHCR poll, operational.
|
||||
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const { listDesignatedPlaylists, isZonedPlaylist, folderSubtree } = require('../
|
|||
const { listLayoutGeometry } = require('../lib/agency-layouts');
|
||||
const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish
|
||||
const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set
|
||||
const { resolveItemDuration } = require('../lib/item-duration');
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
|
@ -112,7 +113,9 @@ router.post('/playlists/:playlistId/items', (req, res) => {
|
|||
if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
|
||||
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
|
||||
}
|
||||
duration_sec = duration_sec || content.duration_sec || 10;
|
||||
// #237: the raw content duration used to be stored as probed (31.7s), and a fraction is
|
||||
// truncated by the Android player's optInt read — so round to whole seconds here too.
|
||||
duration_sec = resolveItemDuration(duration_sec, content);
|
||||
|
||||
const sd = start_date ?? null, ed = end_date ?? null;
|
||||
for (const [k, v] of [['start_date', sd], ['end_date', ed]]) {
|
||||
|
|
|
|||
|
|
@ -8,21 +8,15 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|||
// though playlists.js itself isn't yet workspace-filtered.
|
||||
const { accessContext } = require('../lib/tenancy');
|
||||
const { zoneInLayout } = require('../lib/zone-validate');
|
||||
// #237 + #widget zero-duration loop: one place decides what duration a new item gets —
|
||||
// explicit value, else the content's own length, else the 10s default (and never a 0).
|
||||
const { resolveItemDuration } = require('../lib/item-duration');
|
||||
|
||||
// Mark playlist as draft (called after any item mutation)
|
||||
function markDraft(playlistId) {
|
||||
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
|
||||
}
|
||||
|
||||
// Hardening (#widget zero-duration loop): a non-positive duration — especially
|
||||
// duration_sec=0 on a widget — makes the player schedule a 0ms auto-advance, which
|
||||
// self-loops and black-screens the TV. Never STORE a bad value: floor any missing/
|
||||
// invalid/<1 duration to the 10s default so it can't reach a device.
|
||||
function normalizeDuration(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 10;
|
||||
}
|
||||
|
||||
// Hardening (#zone-orphan): a zone_id only renders if it belongs to the layout the
|
||||
// device is actually showing. Assigning a zone from a DIFFERENT layout (e.g. after a
|
||||
// layout switch/duplicate) creates an item that the players can't place. We CLEAR a
|
||||
|
|
@ -105,17 +99,20 @@ router.post('/device/:deviceId', (req, res) => {
|
|||
const access = checkDeviceAccess(req, res, 'deviceId', true);
|
||||
if (!access) return;
|
||||
const { content_id, widget_id, zone_id, sort_order } = req.body;
|
||||
const duration_sec = normalizeDuration(req.body.duration_sec);
|
||||
|
||||
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
|
||||
|
||||
let content = null;
|
||||
if (content_id) {
|
||||
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
|
||||
content = db.prepare('SELECT id, workspace_id, duration_sec FROM content WHERE id = ?').get(content_id);
|
||||
if (!content) return res.status(404).json({ error: 'Content not found' });
|
||||
if (content.workspace_id && content.workspace_id !== access.device.workspace_id) {
|
||||
return res.status(403).json({ error: 'Content is not in this device\'s workspace' });
|
||||
}
|
||||
}
|
||||
// #237: pushing a video straight at a display is the shortest path in the product, so it
|
||||
// has to default to the clip's length too — not the 10s that cut it off mid-play.
|
||||
const duration_sec = resolveItemDuration(req.body.duration_sec, content);
|
||||
if (widget_id) {
|
||||
const widget = db.prepare('SELECT id, workspace_id FROM widgets WHERE id = ?').get(widget_id);
|
||||
if (!widget) return res.status(404).json({ error: 'Widget not found' });
|
||||
|
|
@ -234,7 +231,7 @@ router.put('/:id', (req, res) => {
|
|||
const values = [];
|
||||
|
||||
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
|
||||
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(normalizeDuration(duration_sec)); }
|
||||
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(resolveItemDuration(duration_sec, null)); }
|
||||
// zone_id can be null (clear the zone) - treat undefined as "no change",
|
||||
// any other value (including null) as "write this".
|
||||
if (zone_id !== undefined) {
|
||||
|
|
@ -337,7 +334,7 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
|
|||
|
||||
const transaction = db.transaction(() => {
|
||||
sourceItems.forEach((a, i) => {
|
||||
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, normalizeDuration(a.duration_sec));
|
||||
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, resolveItemDuration(a.duration_sec, null));
|
||||
});
|
||||
});
|
||||
transaction();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|||
// Phase 2.2b: workspace-aware access. Mirrors the pattern from devices.js.
|
||||
const { accessContext } = require('../lib/tenancy');
|
||||
// #73: the upload ingest (processing + insert) is now shared with the agency router.
|
||||
const { ingestUploadedFile } = require('../lib/content-ingest');
|
||||
const { ingestUploadedFile, deriveMediaMetadata } = require('../lib/content-ingest');
|
||||
const { finalizeUpload, INLINE_SAFE_EXTS } = require('../lib/upload-sniff');
|
||||
|
||||
// Multer captures file.originalname directly from the multipart filename header,
|
||||
|
|
@ -511,24 +511,18 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
|
|||
let filepath, mime;
|
||||
try { ({ filepath, mime } = finalizeUpload(req.file)); }
|
||||
catch (e) { return res.status(e.status || 400).json({ error: e.message }); }
|
||||
let width = null, height = null, thumbnailPath = null;
|
||||
|
||||
// Generate new thumbnail for images (SVG skipped — see lib/content-ingest.js)
|
||||
try {
|
||||
if (mime === 'image/svg+xml') {
|
||||
thumbnailPath = filepath;
|
||||
} else if (mime.startsWith('image/')) {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(req.file.path).metadata();
|
||||
width = metadata.width;
|
||||
height = metadata.height;
|
||||
thumbnailPath = `thumb_${filepath}`;
|
||||
await sharp(req.file.path).resize(config.thumbnailWidth).jpeg({ quality: 70 })
|
||||
.toFile(path.join(config.contentDir, thumbnailPath));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Thumbnail generation failed:', e.message);
|
||||
}
|
||||
// Re-derive EVERYTHING the bytes decide, through the SAME function the upload path uses.
|
||||
// This route used to carry a shorter copy that handled images only, and got three things
|
||||
// wrong that an upload gets right:
|
||||
// - a replaced VIDEO lost its duration (the row kept the OLD clip's length, so #237's
|
||||
// "default an item to the clip's own length" then handed out the wrong number), its
|
||||
// dimensions, and its thumbnail;
|
||||
// - a replaced IMAGE was measured with raw sharp metadata instead of imageDisplayDims and
|
||||
// thumbnailed without .rotate(), re-introducing the EXIF-orientation bug (#170) that
|
||||
// ingest fixes — a portrait photo came back landscape with blue bars;
|
||||
// - both left width/height NULL for video, which is what the orientation-aware paths read.
|
||||
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(req.file.path, filepath, mime);
|
||||
|
||||
// Bump the revision: this is the ONLY operation in the product that changes an asset's bytes
|
||||
// without changing its id, so it is the only thing that can make a player's cached copy wrong.
|
||||
|
|
@ -537,11 +531,15 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
|
|||
// strftime seconds can collide with the previous value if a replace lands inside the same second
|
||||
// as the upload (a small file, a scripted replace) — and a revision that does not change is a
|
||||
// cache that never updates. MAX(now, previous + 1) guarantees it moves.
|
||||
// duration_sec comes from the NEW bytes. COALESCE-to-NULL rather than keeping the old value:
|
||||
// a replace that turns a video into an image genuinely has no duration, and a stale one would
|
||||
// silently become the default for every later playlist add (lib/item-duration.js).
|
||||
db.prepare(`UPDATE content
|
||||
SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ?,
|
||||
duration_sec = ?,
|
||||
updated_at = MAX(CAST(strftime('%s','now') AS INTEGER), COALESCE(NULLIF(updated_at, 0), created_at) + 1)
|
||||
WHERE id = ?`)
|
||||
.run(filepath, mime, req.file.size, thumbnailPath, width, height, req.params.id);
|
||||
.run(filepath, mime, req.file.size, thumbnailPath, width, height, durationSec, req.params.id);
|
||||
|
||||
// ...and tell the panels, which the old code did not. Without this the new bytes reached a screen
|
||||
// only when something else happened to trigger a playlist refresh — an operator replacing a video
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue