mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 15:23:16 -06:00
Compare commits
310 commits
| 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 | ||
|
|
ba45c2d60c | ||
|
|
75c1940821 | ||
|
|
684e60fc55 | ||
|
|
cf3b2e62af | ||
|
|
3e6c97ba10 | ||
|
|
4153448c55 | ||
|
|
6310f48590 | ||
|
|
f8e359895d | ||
|
|
90eee4ce45 | ||
|
|
0082191f9b | ||
|
|
e5583e529e | ||
|
|
c4ee7d008f | ||
|
|
a08e6c3e06 | ||
|
|
5fe55307ba | ||
|
|
c07a56b47d | ||
|
|
812e89f28f | ||
|
|
6bc709d2f7 | ||
|
|
d205a49dfa | ||
|
|
b419830629 | ||
|
|
803f4ec26d | ||
|
|
604c390a55 | ||
|
|
1c7d5f1359 | ||
|
|
df3a2879fa | ||
|
|
7233466030 | ||
|
|
3059d8cd5c | ||
|
|
c170861124 | ||
|
|
de161bf43a | ||
|
|
30a71c1319 | ||
|
|
cf1124d687 | ||
|
|
4ed7954f84 | ||
|
|
039511b988 | ||
|
|
9c04e2c113 | ||
|
|
16b3dd949c | ||
|
|
90553852bf | ||
|
|
8fd6eb75d5 | ||
|
|
5a7277523a | ||
|
|
46b2227dfd | ||
|
|
e606cc83d1 | ||
|
|
141deb97a5 | ||
|
|
c743aa4b81 | ||
|
|
cb4376d9cd | ||
|
|
5ce094b1f8 | ||
|
|
fac6071813 | ||
|
|
f86195df53 | ||
|
|
58641e7bbe | ||
|
|
5cd56344d2 | ||
|
|
ad18914736 | ||
|
|
b15b17f5dd | ||
|
|
4e625abf50 | ||
|
|
5901067d8a | ||
|
|
bc68cd2752 | ||
|
|
7fb94fbf70 | ||
|
|
fa68c8b7e3 | ||
|
|
ce854ff2d8 | ||
|
|
6f5907a1d4 | ||
|
|
88f2c63229 | ||
|
|
fca36c242a | ||
|
|
ff7bfb2ded | ||
|
|
921c7ce3bb | ||
|
|
d51138624e | ||
|
|
ccbd63ba79 | ||
|
|
a310d7d5b6 | ||
|
|
9458af95fd | ||
|
|
47bda040a2 | ||
|
|
e3b01a5c7f | ||
|
|
3c1b2f62ee | ||
|
|
416984d56b | ||
|
|
80c8c81fb8 | ||
|
|
4a65c4cec7 | ||
|
|
9ea1b5e07b | ||
|
|
d978a5d2a6 | ||
|
|
f4d309a0d4 | ||
|
|
d3f6af831b | ||
|
|
f66c941c1d | ||
|
|
a3b668d32f | ||
|
|
fb8cafc444 | ||
|
|
64a6bfd860 | ||
|
|
d580994bb3 | ||
|
|
acadb4c1f4 | ||
|
|
6e9a1f9711 | ||
|
|
88e4a9eb49 | ||
|
|
9c6b80c411 | ||
|
|
14367af5f1 | ||
|
|
9958c7c7be | ||
|
|
c393cf8ab3 | ||
|
|
81f5d4f9f3 | ||
|
|
e0bdd3b65c | ||
|
|
5c6e0325b1 | ||
|
|
6e3be7a95a | ||
|
|
cad19abee1 | ||
|
|
abdb3b434d | ||
|
|
4cc750ba3a | ||
|
|
452c286357 | ||
|
|
752f39ea43 | ||
|
|
f7bf9412e4 | ||
|
|
6d33d00cd0 | ||
|
|
b44f9d4f03 | ||
|
|
d70764991e | ||
|
|
ac68e95b2b | ||
|
|
dd7596a674 | ||
|
|
234bff795d | ||
|
|
2bf8b4271f | ||
|
|
301c76c3f7 | ||
|
|
5297f091af | ||
|
|
3b23600d97 | ||
|
|
0b9d9aff76 | ||
|
|
c483ef34dd | ||
|
|
9034478c28 | ||
|
|
40035533e5 | ||
|
|
275e1683b8 | ||
|
|
54f1f62762 | ||
|
|
ead452d9b1 | ||
|
|
3f0db335d2 | ||
|
|
2906e559cb | ||
|
|
bb016b8313 | ||
|
|
a25c6827a7 | ||
|
|
10f1dccdc8 | ||
|
|
3159f94107 | ||
|
|
39c4ec8af8 | ||
|
|
8eff6d57d1 | ||
|
|
b09bed645d | ||
|
|
83c9bc5aa6 | ||
|
|
ce626c7e8e | ||
|
|
bc00bc1eb1 | ||
|
|
3a681abda0 | ||
|
|
66d9dc7fef |
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
|
||||
|
|
|
|||
87
.github/workflows/release.yml
vendored
87
.github/workflows/release.yml
vendored
|
|
@ -79,6 +79,21 @@ jobs:
|
|||
cp tizen/ScreenTinker.wgt ScreenTinker.wgt
|
||||
ls -la ScreenTinker.wgt
|
||||
|
||||
- name: Build BrightSign autorun.zip (single-file player installer)
|
||||
run: |
|
||||
chmod +x scripts/build-autorun-zip.sh
|
||||
./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"
|
||||
|
|
@ -87,22 +102,49 @@ jobs:
|
|||
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
|
||||
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
|
||||
--exclude='*.apk' \
|
||||
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt
|
||||
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt brightsign
|
||||
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
|
||||
ls -la "$OUT"
|
||||
|
||||
- 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"
|
||||
|
|
@ -111,12 +153,17 @@ jobs:
|
|||
echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes"
|
||||
echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher"
|
||||
echo " at \`https://<your-instance>/player\` (no signing needed)."
|
||||
echo "- \`autorun.zip\` - BrightSign player installer. Drop it on the root of a player's"
|
||||
echo " storage (microSD, USB, or internal flash) and power-cycle: it unpacks itself and"
|
||||
echo " reboots into the player. Edit \`screentinker.json\` inside the archive first to"
|
||||
echo " point it at your own server."
|
||||
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)."
|
||||
else
|
||||
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
|
||||
|
||||
|
|
@ -133,6 +180,8 @@ jobs:
|
|||
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
|
||||
--notes-file RELEASE_NOTES.md \
|
||||
"${TARBALL}" \
|
||||
autorun.zip \
|
||||
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
|
||||
tizen/ScreenTinker.wgt
|
||||
|
||||
docker:
|
||||
|
|
@ -160,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
|
||||
|
|
|
|||
1493
CHANGELOG.md
1493
CHANGELOG.md
File diff suppressed because it is too large
Load diff
15
Dockerfile
15
Dockerfile
|
|
@ -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).
|
||||
|
|
@ -39,6 +46,12 @@ COPY VERSION /app/VERSION
|
|||
COPY docs/openapi.yaml /app/docs/openapi.yaml
|
||||
# database.js requires scripts/migrate-multitenancy at boot
|
||||
COPY scripts/ /app/scripts/
|
||||
# The BrightSign bridge and sync modules are served to the player from ../brightsign so the copy
|
||||
# the player loads can never drift from the one on the player's own storage. That RUNTIME path
|
||||
# does not exist unless the directory is in the image: without this the routes 404 in a container
|
||||
# while working perfectly from a dev checkout — and a missing player asset fails silently, because
|
||||
# the SPA fallback answers 200 with HTML where JavaScript was expected.
|
||||
COPY brightsign/ /app/brightsign/
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
454
README.md
454
README.md
|
|
@ -32,7 +32,7 @@ ScreenTinker is a free, open-source **digital signage CMS** you can self-host on
|
|||
- **Widgets** — clocks, weather, RSS tickers, text/HTML, webpages, social feeds, and Directory Board (scrolling lobby tenant/room/staff directories with dark/light themes, category management, and anti-burn-in motion)
|
||||
- **Kiosk mode** — interactive touchscreen interfaces
|
||||
- **Proof-of-play** — per-content and per-device analytics, hourly/daily breakdowns, CSV export for ad verification
|
||||
- **Device telemetry** — battery, storage, RAM, CPU, WiFi signal strength, and uptime reported by Android players
|
||||
- **Device telemetry** — battery, storage, RAM, CPU, Wi-Fi signal strength and uptime reported by the players, plus both of a display's addresses: its **local (LAN) IP** as the player sees itself, and the public/WAN address the server saw it connect from. Wi-Fi network name is included where the platform allows it (Android 10+ needs an opt-in location permission — see Device Setup)
|
||||
- **Offline resilience** — both web and Android players keep displaying cached content during server or internet outages (Android ContentCache, web player Service Worker); state syncs when connectivity returns
|
||||
- **Mobile-responsive** — full management dashboard and landing page work on phones and tablets
|
||||
- **Workspaces** — multi-tenant data model: organizations contain workspaces, workspaces contain devices/content/playlists/schedules; users can be members of multiple workspaces and switch via a dropdown in the sidebar
|
||||
|
|
@ -46,6 +46,7 @@ ScreenTinker is a free, open-source **digital signage CMS** you can self-host on
|
|||
- **Security** — JWT auth, bcrypt hashing, parameterized SQL, rate-limited endpoints, per-user ownership checks on all resources, ongoing auth/IDOR/XSS audits
|
||||
- **Built-in billing** — Stripe integration for SaaS subscriptions (optional)
|
||||
- **Auto-update** — OTA updates pushed to devices automatically
|
||||
- **Public REST API** — scoped personal access tokens (`read` / `write` / `full`) over the same resources the dashboard uses, workspace-confined by construction. Documented as an OpenAPI 3.1 contract ([`docs/openapi.yaml`](docs/openapi.yaml)) and browsable on any instance at `/docs` (served locally, no CDN, so it works air-gapped)
|
||||
- **Activity log** — full audit trail of user and system actions
|
||||
|
||||
## Architecture
|
||||
|
|
@ -92,7 +93,19 @@ Schema migrations run automatically the first time the server starts after a git
|
|||
|
||||
## Supported Platforms
|
||||
|
||||
Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser.
|
||||
Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, BrightSign, and any
|
||||
device with a web browser.
|
||||
|
||||
Anything with a reasonably modern browser can be a display without installing anything: point it at
|
||||
`/player`. The native players add what a browser cannot: the **Android APK** gives you unattended boot,
|
||||
OTA self-update, remote power and touch injection, and a content cache that survives a reboot; the
|
||||
**Tizen `.wgt`** gives you an installed app that launches itself on the TV. Tizen does not
|
||||
self-update — new versions are installed the same way the first one was.
|
||||
|
||||
> **BrightSign** runs the unmodified browser player (verified on Series 5 / Chromium 120) and needs
|
||||
> no separate build. One caveat worth knowing before you rely on it: BrightSign's HTML widget does
|
||||
> not always survive the page reload the player performs when you deploy new content, and may need a
|
||||
> restart to come back. Treat it as working but less hands-off than the native players.
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
|
|
@ -101,6 +114,11 @@ Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, a
|
|||
- 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
|
||||
|
||||
|
|
@ -161,6 +179,75 @@ Two things to know before enabling it:
|
|||
- **It is read by the player, not the server**, so only players new enough to understand
|
||||
`allow_managed` honour it. Older players keep standing down regardless.
|
||||
|
||||
#### When a display will not update itself
|
||||
|
||||
OTA is per-display and can be turned off per display. If one is not taking an update, the order to
|
||||
check is:
|
||||
|
||||
1. **Is OTA enabled for it?** There is a per-display toggle; a display with it off will never
|
||||
self-update, by design.
|
||||
2. **Is it standing down for an MDM?** It reports `manual_update_required` if so — see above.
|
||||
3. **Has it been retrying and failing?** Retrying and telling you about it are two separate
|
||||
things, on purpose:
|
||||
|
||||
- After **3** failed installs the display **flags itself as needing attention** in the dashboard.
|
||||
A human is demonstrably required by then, so it says so early rather than at the end.
|
||||
- It **keeps retrying anyway**, up to 40 attempts. Attempts after the first are close to free —
|
||||
the APK is downloaded and signature-checked once and then reused from cache, so retry number
|
||||
twelve pulls no bytes.
|
||||
- Past that it settles to about **one attempt a day**, indefinitely. It never gives up for good,
|
||||
and a new version clears the count — so a display stuck for a week still picks up the next
|
||||
release on its own.
|
||||
|
||||
The flag is what to watch for. Silence is not the signal.
|
||||
|
||||
**Force update** — per display, or as a group command — deliberately ignores the back-off, the
|
||||
attempt count *and* the MDM stand-down, and tries straight away. It reports back either way,
|
||||
including "already up to date", so the button never just appears to do nothing. What it cannot do is
|
||||
invent permissions: if installs need a confirmation tap on that hardware, forcing still raises the
|
||||
dialog. It is the right button once you have fixed whatever was breaking the update.
|
||||
|
||||
#### Running a beta channel
|
||||
|
||||
By default an instance serves one APK to every display, at `/download/apk`. You can publish a second
|
||||
build alongside it and send it only to displays you choose:
|
||||
|
||||
1. Put the beta APK next to the stable one, as **`ScreenTinker-beta.apk`** (same locations as
|
||||
`ScreenTinker.apk` — `/data/` in a container, or the install root).
|
||||
2. Declare its version in a sidecar text file, **`ScreenTinker-beta.apk.version`**, containing just
|
||||
the version — e.g. `1.9.27-rc1`. This is required. The server cannot read the version out of an
|
||||
APK cheaply, and advertising a version that does not match the bytes it serves is how update
|
||||
loops start, so **a beta with no declared version is ignored entirely** and opted-in displays
|
||||
keep getting the stable build.
|
||||
3. Tick **Accept pre-release builds** on any display that should receive it.
|
||||
|
||||
Untick the box to move a display back to the release build — the server offers it the stable build
|
||||
even though it is technically "older" than the beta. Displays you never put on the channel are
|
||||
untouched by any of this.
|
||||
|
||||
> **Cut beta builds with the same `versionCode` as the stable release they branch from.** Android
|
||||
> refuses to install a lower `versionCode`, so a beta numbered above stable can be installed but
|
||||
> never returned without uninstalling the app (which loses the display's pairing). Equal numbers
|
||||
> install in both directions, which is what makes switching back work.
|
||||
|
||||
#### Deleting and re-pairing a display
|
||||
|
||||
A display's settings are keyed to the hardware, not to its row in the database. Delete a display and
|
||||
pair the same panel again and it comes back with its previous **name, orientation, timezone, notes
|
||||
and assigned playlist** already set — you do not have to configure it twice, and a panel that is
|
||||
physically hard to reach does not need a visit. (The playlist only returns if it still exists; a
|
||||
deleted one is not resurrected.)
|
||||
|
||||
Two consequences that are easy to misread:
|
||||
|
||||
- The old playlist reappearing is ScreenTinker restoring it, not a bug. If you deleted the display
|
||||
in order to *clear* it, change the playlist after re-pairing rather than before.
|
||||
- **A blocked display stays blocked**, deliberately. Blocking is a security control, so it must not
|
||||
be defeatable by deleting the display and pairing again. Use **Unblock** — that clears the stored
|
||||
block as well as the live one. (Before 1.9.25, Unblock only cleared the live one and the block came
|
||||
back on the next re-pair; if you have a display that refuses to pair for no visible reason, unblock
|
||||
it once on this version.)
|
||||
|
||||
#### Raising the upload limit
|
||||
|
||||
`MAX_FILE_SIZE` sets what **the application** accepts. It is usually not the only limit, and it
|
||||
|
|
@ -218,31 +305,256 @@ If you want to charge your users, plug in your own Stripe keys. Without them, al
|
|||
|
||||
The default plans are: Free (2 devices), Starter (8 devices), Pro (25 devices), and Enterprise (unlimited). Edit the `plans` table to change pricing, limits, or add/remove tiers. In self-hosted mode, the first user gets Enterprise automatically.
|
||||
|
||||
#### Google OAuth
|
||||
#### Plans and comped accounts
|
||||
|
||||
Let users sign in with Google.
|
||||
Platform admins get a plan overview under **Admin → Subscription Plans**: every plan on the instance with how
|
||||
many accounts, organizations and displays are on each, so you can see what people actually use
|
||||
before changing a price or retiring a tier. It also flags accounts pointing at a plan that no longer
|
||||
exists, which otherwise surfaces only as odd entitlement behaviour.
|
||||
|
||||
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
|
||||
A plan marked **inactive** disappears from the customer-facing pricing page but keeps working
|
||||
normally for anyone already on it. That is how you run a comped, beta or legacy tier without
|
||||
advertising it — put the account on the hidden plan and it simply gets those limits. The overview
|
||||
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.
|
||||
|
||||
#### Single sign-on (OpenID Connect)
|
||||
|
||||
> **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.
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -318,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:
|
||||
|
|
@ -330,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
|
||||
|
|
@ -404,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:
|
||||
|
|
@ -478,6 +817,24 @@ Locked out? Run this on the server to get a temporary admin token (1 hour):
|
|||
node scripts/reset-admin.js
|
||||
```
|
||||
|
||||
### Forcing an update on one display
|
||||
|
||||
A display whose periodic update checker isn't firing won't pull a new APK on its own, and putting it
|
||||
on the beta channel alone won't reach it either. This sends the same forced check the dashboard's
|
||||
force-update button sends — it ignores the backoff cap and the MDM stand-down:
|
||||
|
||||
```bash
|
||||
node scripts/force-update.js --list # displays online right now
|
||||
node scripts/force-update.js <id-or-prefix> # force a check on one display
|
||||
node scripts/force-update.js <id> --dry-run # prove auth/handshake, send nothing
|
||||
```
|
||||
|
||||
Run it on the server (it needs the database and `JWT_SECRET`, and mints a short-lived
|
||||
`platform_admin` token). On a display that isn't device-owner provisioned the install raises a
|
||||
confirm dialog **over whatever is on screen** and leaves it there until someone accepts, so aim it at
|
||||
one display when a person can see it. Updating preserves runtime permissions; only uninstalling
|
||||
clears them.
|
||||
|
||||
### Building the Android APK
|
||||
|
||||
The Android player app is in the `android/` directory. To build it:
|
||||
|
|
@ -521,13 +878,70 @@ 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.
|
||||
|
||||
One of those rows is **optional and off by default**: granting location lets the player report the
|
||||
**Wi-Fi network name** for the display. Android 10 and later will not reveal the SSID without it.
|
||||
Nothing else changes if you skip it — the display works identically and still reports signal
|
||||
strength, and the dashboard says the network name needs that permission rather than showing a blank.
|
||||
|
||||
> **One playlist per display, and how to run more.** A display has a single playlist at a time.
|
||||
> To rotate between several, use **Scheduling** — "Playlist A 9am-5pm, Playlist B evenings", or
|
||||
> different playlists on different days — and the display switches on its own, offline included,
|
||||
> once the schedule has reached it.
|
||||
|
||||
> **Troubleshooting a player** (stuck on "Connecting to server", re-pointing a
|
||||
> device to a different server, or connecting adb over Wi-Fi): see
|
||||
> [docs/android-troubleshooting.md](docs/android-troubleshooting.md).
|
||||
|
|
|
|||
|
|
@ -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? ?: "88").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.24"
|
||||
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 {
|
||||
|
|
@ -43,6 +43,14 @@ android {
|
|||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
// ScheduleEval uses java.time (Instant/LocalDate/ZoneId), which is API 26 — but minSdk is
|
||||
// 24. Without desugaring, per-item dayparting/expiry threw NoClassDefFoundError on Android
|
||||
// 7.0/7.1, which are still common on cheap signage sticks and older TV boxes. Because that
|
||||
// is an Error and not an Exception, the evaluator's deliberate fail-open guard did not
|
||||
// catch it: the playlist update aborted before content downloaded, and the cold-start path
|
||||
// then cleared the playlist cache — so the screen sat on "waiting for content" and a reboot
|
||||
// did not help.
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
|
|
@ -63,6 +71,7 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
// AndroidX
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
|
@ -78,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")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- OPTIONAL, and never requested at startup. Android 8.1+ hides the connected Wi-Fi network
|
||||
name from apps without location permission, so the device page can only show "unavailable"
|
||||
without it. A signage player should not demand location to display a network name, so this
|
||||
is opt-in from the setup screen and nothing else depends on it: not granting it changes
|
||||
only that one field. Coarse is enough below Android 10; fine is required from 10 onwards. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -227,13 +248,17 @@ class MainActivity : AppCompatActivity() {
|
|||
val cid = item.contentId.ifEmpty { item.widgetId ?: "" }
|
||||
if (event == "play_start") wsService?.sendPlayStart(cid, item.filename, item.durationSec)
|
||||
else wsService?.sendPlayEnd(cid, item.filename, completed)
|
||||
}
|
||||
},
|
||||
// #234: carry the playback position across Activity rebuilds. Without this a relaunch
|
||||
// restarts the playlist at item 1, so anything after it never gets a turn.
|
||||
loadResume = { config.resumeIndex.takeIf { it >= 0 }?.let { it to config.resumeAt } },
|
||||
saveResume = { index, atMs -> config.resumeIndex = index; config.resumeAt = atMs }
|
||||
)
|
||||
// Screen-resilience: an item is playable only when its content is actually available —
|
||||
// a widget, a remote stream, or a fully-downloaded local file. A not-yet/failed download is
|
||||
// skipped (kept in the background) instead of blanking the screen on a loading state.
|
||||
playlistController.setContentReadyCheck { item ->
|
||||
item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId)
|
||||
item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId, item.contentRev)
|
||||
}
|
||||
|
||||
// feat/transition-engine: full-screen GLES2 overlay that plays image/video wipes. Inserted just
|
||||
|
|
@ -366,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
|
||||
|
|
@ -443,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) {
|
||||
|
|
@ -472,25 +551,93 @@ 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() {
|
||||
// #170: on a fresh network connection, clear stuck download backoff so content that failed
|
||||
// to download while the link was settling retries on the next sweep (the service also
|
||||
// requests a playlist refresh). Keeps single-flight; only touches failure/backoff state.
|
||||
// #234: the server rejecting us (operator block, cleared credentials, reclaim settle) has
|
||||
// to be VISIBLE. Only ProvisioningActivity ever assigned onUnpaired, and it is gone by the
|
||||
// time playback is running — so a rejection left the screen sitting on "Connecting to
|
||||
// server", and the player then blamed the URL. The server always says why; show that.
|
||||
wsService?.onUnpaired = {
|
||||
runOnUiThread {
|
||||
val why = wsService?.lastRejectionReason ?: ""
|
||||
val blocked = why.contains("block", ignoreCase = true)
|
||||
val transient = wsService?.lastRejectionTransient == true
|
||||
Log.w("MainActivity", "server rejected this device ($why, transient=$transient)")
|
||||
showStatus(
|
||||
if (blocked) getString(R.string.device_blocked_status)
|
||||
else getString(R.string.device_unpaired_status)
|
||||
)
|
||||
|
||||
// A TRANSIENT rejection (the reclaim-settle hold: "retry after N seconds") is one
|
||||
// the service recovers from by itself — it holds, retries once and comes back. Tear
|
||||
// nothing down for it. The previous handler did the opposite: it wiped the offline
|
||||
// playlist cache and jumped to provisioning on every rejection, so a self-healing
|
||||
// hold cost the panel its cache and forced a full re-download after re-pairing.
|
||||
//
|
||||
// A terminal rejection means this device really is gone from the server, and the
|
||||
// operator needs the pairing code, so provisioning is right. The cache is kept
|
||||
// either way: it is what lets the screen keep showing content while someone walks
|
||||
// over to re-pair it, and re-pairing restores the settings anyway.
|
||||
if (!transient && !blocked) {
|
||||
handler.post {
|
||||
startActivity(Intent(this@MainActivity, ProvisioningActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
// Server-initiated re-pair (known-good URL): show the code, not URL entry.
|
||||
putExtra("EXTRA_REPAIR", true)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wsService?.onNetworkAvailable = {
|
||||
if (::downloadCoordinator.isInitialized) downloadCoordinator.resetAllBackoff()
|
||||
}
|
||||
|
|
@ -549,14 +696,30 @@ class MainActivity : AppCompatActivity() {
|
|||
val currentLayoutId = zoneManager?.currentLayoutId
|
||||
|
||||
// Build a signature of current assignments to detect content changes
|
||||
// widget_rev belongs in here for the same reason it is in the fullscreen playlist
|
||||
// signature: editing a widget changes its CONTENT, never its id, so without it a
|
||||
// zone assignment looked identical and the re-render was skipped as "unchanged".
|
||||
val assignmentSig = (0 until assignments.length()).map { i ->
|
||||
val a = assignments.getJSONObject(i)
|
||||
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}"
|
||||
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}:${a.optLong("widget_rev", 0L)}"
|
||||
}.sorted().joinToString("|")
|
||||
val changed = assignmentSig != zoneManager?.lastAssignmentSig
|
||||
|
||||
// The ZONES themselves can change without the layout id changing — editing a layout
|
||||
// in place (adding a 4th zone to a 3-zone layout) keeps the same id. Rebuilding only
|
||||
// on an id change meant the new zone never appeared: the geometry stayed as it was
|
||||
// and only the assignments re-rendered into the OLD zones, so the change looked like
|
||||
// it had been ignored until the app was force-stopped. Reported on #234.
|
||||
val zoneSig = (0 until layoutZones.length()).map { i ->
|
||||
val z = layoutZones.getJSONObject(i)
|
||||
"${z.optString("id")}:${z.optDouble("x_percent", -1.0)}:${z.optDouble("y_percent", -1.0)}:" +
|
||||
"${z.optDouble("width_percent", -1.0)}:${z.optDouble("height_percent", -1.0)}:" +
|
||||
"${z.optInt("z_index", 0)}:${z.optString("zone_type")}:${z.optString("fit_mode")}"
|
||||
}.sorted().joinToString("|")
|
||||
val zonesChanged = zoneSig != zoneManager?.lastZoneSig
|
||||
|
||||
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
|
||||
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
|
||||
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId || zonesChanged) {
|
||||
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
|
||||
handler.post {
|
||||
hideStatus()
|
||||
|
|
@ -567,6 +730,7 @@ class MainActivity : AppCompatActivity() {
|
|||
zoneManager?.setupZones(layoutZones, layoutId)
|
||||
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId)
|
||||
zoneManager?.lastAssignmentSig = assignmentSig
|
||||
zoneManager?.lastZoneSig = zoneSig
|
||||
}
|
||||
} else if (changed) {
|
||||
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
|
||||
|
|
@ -609,6 +773,10 @@ class MainActivity : AppCompatActivity() {
|
|||
val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "")
|
||||
if (contentId.isEmpty()) continue
|
||||
val filename = item.optString("filename", "content")
|
||||
// Bumped when the bytes behind this id change. A cached copy at a different revision is
|
||||
// a MISS: the id, the filename and the URL are all identical after a replace, so this
|
||||
// is the only thing that can tell a panel its copy is out of date.
|
||||
val contentRev = item.optLong("content_rev", 0L)
|
||||
// org.json's optString(key, null) returns the STRING "null" when the value is JSON
|
||||
// null (not the fallback) — so a local item with "remote_url": null was being
|
||||
// misclassified as a remote stream, ack'd "ready", and NEVER downloaded, stranding
|
||||
|
|
@ -628,7 +796,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// defers when the socket is down (watchdog owns recovery), respects backoff, and
|
||||
// downloads at most once. It acks ready/failed itself (deduped via onAck).
|
||||
if (contentChanged) downloadCoordinator.resetBackoff(contentId) // #170: retry now, don't wait out a stale backoff
|
||||
downloadCoordinator.ensure(contentId, filename)
|
||||
downloadCoordinator.ensure(contentId, filename, contentRev)
|
||||
}
|
||||
|
||||
// Start/resume playback immediately — do NOT wait on downloads (they're async now).
|
||||
|
|
@ -720,19 +888,39 @@ class MainActivity : AppCompatActivity() {
|
|||
?: Log.w("MainActivity", "screen_off/lock_now: no owner/admin/accessibility — unsupported")
|
||||
}
|
||||
}
|
||||
// No reliable privileged wake on a non-rooted panel (the old keyevent 224 was denied);
|
||||
// retired to a logged no-op.
|
||||
"screen_on" -> Log.w("MainActivity", "screen_on: no privileged wake path on a non-rooted panel — no-op")
|
||||
// Was a logged no-op: the retired `input keyevent 224` is denied to an app UID, and
|
||||
// that one failure was read as "no wake path exists". A wake LOCK is a different
|
||||
// mechanism needing only WAKE_LOCK, which we already hold — so screen_off worked
|
||||
// and screen_on did not, and an operator who slept a panel overnight had to drive
|
||||
// out to wake it. Losing the screen is the expensive direction to fail in.
|
||||
"screen_on" -> {
|
||||
val woke = systemControl.wakeScreen()
|
||||
// The wake lock lights the panel; on a locked device the keyguard is still in
|
||||
// front of the player, so ask for it to be dismissed too. Both are best-effort
|
||||
// and independent — a device that ignores one may honour the other.
|
||||
runOnUiThread {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
(getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager)
|
||||
?.requestDismissKeyguard(this, null)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) { Log.w("MainActivity", "screen_on keyguard: ${e.message}") }
|
||||
}
|
||||
Log.i("MainActivity", "screen_on: wake=$woke")
|
||||
}
|
||||
// #161 Tier-2 (all no-op off-owner via STPolicy): kiosk lock-task, time/tz, status bar,
|
||||
// uninstall block. Device owner enters lock-task silently; others get screen-pinning.
|
||||
"kiosk_lock" -> {
|
||||
stPolicy().setLockTaskAllowed(true)
|
||||
try { startLockTask() } catch (e: Throwable) { Log.w("MainActivity", "startLockTask: ${e.message}") }
|
||||
}
|
||||
"kiosk_unlock" -> {
|
||||
try { stopLockTask() } catch (e: Throwable) { Log.w("MainActivity", "stopLockTask: ${e.message}") }
|
||||
stPolicy().setLockTaskAllowed(false)
|
||||
}
|
||||
"kiosk_lock" -> setKioskMode(true)
|
||||
"kiosk_unlock" -> setKioskMode(false)
|
||||
"set_time" -> { val ms = payload?.optLong("millis", 0L) ?: 0L; if (ms > 0) stPolicy().setTime(ms) }
|
||||
"set_timezone" -> { val tz = payload?.optString("timezone", "") ?: ""; if (tz.isNotEmpty()) stPolicy().setTimeZone(tz) }
|
||||
"status_bar" -> stPolicy().setStatusBarDisabled(payload?.optBoolean("disabled", true) ?: true)
|
||||
|
|
@ -753,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) {
|
||||
|
|
@ -819,19 +1015,6 @@ class MainActivity : AppCompatActivity() {
|
|||
ackedContent.clear()
|
||||
}
|
||||
|
||||
wsService?.onUnpaired = {
|
||||
Log.w("MainActivity", "Device removed from server, going to provisioning for re-pair")
|
||||
config.clearPlaylistCache()
|
||||
handler.post {
|
||||
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
// Tell provisioning this is a server-initiated re-pair (known-good URL) so it
|
||||
// shows a "waiting for re-pair" status + the code instead of the URL entry.
|
||||
putExtra("EXTRA_REPAIR", true)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root-2 content-ack de-dup. Re-acking content state (SEED-A) fixes the CMS "stuck downloading"
|
||||
|
|
@ -856,8 +1039,11 @@ class MainActivity : AppCompatActivity() {
|
|||
// layouts; multi-zone widgets go through ZoneManager). Previously unhandled,
|
||||
// so widgets were blank/broken in default-fullscreen and the fullscreen template.
|
||||
if (item.isWidget) {
|
||||
// rev makes the URL change when — and only when — the widget's content changed, so an
|
||||
// edit reloads while an untouched widget still hits the no-flash reuse path.
|
||||
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" +
|
||||
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "")
|
||||
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "?d=") +
|
||||
"&rev=${item.widgetRev}"
|
||||
Log.i("MainActivity", "Playing widget fullscreen: $url")
|
||||
mediaPlayer.showWidget(url)
|
||||
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
|
||||
|
|
@ -893,7 +1079,7 @@ class MainActivity : AppCompatActivity() {
|
|||
val file = contentCache.getCachedFile(item.contentId)
|
||||
if (file == null) {
|
||||
Log.i("MainActivity", "Content not ready at play time (${item.filename}) — keeping screen, advancing (bg download continues)")
|
||||
downloadCoordinator.ensure(item.contentId, item.filename) // ensure it's being fetched (single-flight)
|
||||
downloadCoordinator.ensure(item.contentId, item.filename, item.contentRev) // ensure it's being fetched (single-flight)
|
||||
handler.post { playlistController.next() }
|
||||
return
|
||||
}
|
||||
|
|
@ -1089,10 +1275,20 @@ class MainActivity : AppCompatActivity() {
|
|||
getString(R.string.settings_exit)
|
||||
)
|
||||
|
||||
// Requested from the field: with kiosk on, the PIN menu is the ONLY way back out on a
|
||||
// panel with no other input. Shown only when locked — an entry that does nothing is worse
|
||||
// than no entry, and this menu is already long.
|
||||
val kiosk = kioskModeEnabled()
|
||||
val menu = if (kiosk) items + getString(R.string.settings_exit_kiosk) else items
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.settings_title))
|
||||
.setItems(items) { _, which ->
|
||||
.setItems(menu) { _, which ->
|
||||
when (which) {
|
||||
items.size -> { // the appended exit-kiosk entry; only present when locked
|
||||
setKioskMode(false)
|
||||
Toast.makeText(this, getString(R.string.settings_exit_kiosk_done), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
0 -> showChangeServerDialog(serverUrl)
|
||||
1 -> {
|
||||
config.clearDeviceCredentials()
|
||||
|
|
@ -1108,6 +1304,48 @@ class MainActivity : AppCompatActivity() {
|
|||
.show()
|
||||
}
|
||||
|
||||
/*
|
||||
* Kiosk lock, remembered.
|
||||
*
|
||||
* startLockTask() is a runtime call on the Activity — it does not survive a reboot. A panel
|
||||
* locked from the dashboard came back up unlocked, with nothing to say so, and the only way
|
||||
* to notice was that someone could suddenly leave the app. Reported from the field.
|
||||
*
|
||||
* The flag is written BEFORE the lock is attempted so a device that reboots mid-call still
|
||||
* comes back in the state the operator asked for; a lock that then fails is retried on the
|
||||
* next start rather than being forgotten.
|
||||
*/
|
||||
private fun setKioskMode(enabled: Boolean) {
|
||||
try {
|
||||
getSharedPreferences("screentinker", Context.MODE_PRIVATE)
|
||||
.edit().putBoolean("kiosk_enabled", enabled).apply()
|
||||
} catch (e: Throwable) { Log.w("MainActivity", "kiosk pref: ${e.message}") }
|
||||
|
||||
if (enabled) {
|
||||
stPolicy().setLockTaskAllowed(true)
|
||||
try { startLockTask() } catch (e: Throwable) { Log.w("MainActivity", "startLockTask: ${e.message}") }
|
||||
} else {
|
||||
try { stopLockTask() } catch (e: Throwable) { Log.w("MainActivity", "stopLockTask: ${e.message}") }
|
||||
stPolicy().setLockTaskAllowed(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun kioskModeEnabled(): Boolean = try {
|
||||
getSharedPreferences("screentinker", Context.MODE_PRIVATE).getBoolean("kiosk_enabled", false)
|
||||
} catch (e: Throwable) { false }
|
||||
|
||||
/** Re-enter lock task after a restart, if that is the state the operator left it in. */
|
||||
private fun restoreKioskMode() {
|
||||
if (!kioskModeEnabled()) return
|
||||
stPolicy().setLockTaskAllowed(true)
|
||||
try {
|
||||
startLockTask()
|
||||
Log.i("MainActivity", "Kiosk mode restored after restart")
|
||||
} catch (e: Throwable) {
|
||||
Log.w("MainActivity", "Kiosk restore failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// #161: device-policy wrapper (degrades safely off-tier — every Tier-2 call no-ops when not owner).
|
||||
private fun stPolicy() = com.remotedisplay.player.admin.STPolicy(this)
|
||||
// #160 Track-A: no-device-owner system control (media volume, brightness, screen-off timeout).
|
||||
|
|
@ -1194,7 +1432,17 @@ class MainActivity : AppCompatActivity() {
|
|||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.settings_permissions))
|
||||
.setMessage(lines)
|
||||
.setPositiveButton(getString(R.string.settings_perm_open)) { _, _ ->
|
||||
// Primary action opens OUR permissions screen — the one with a row per permission and
|
||||
// a Manage button that stays visible once granted, so an installer can review or revoke
|
||||
// what was given. Android's App Info page is still offered as the secondary, because a
|
||||
// few things (notification access, some OEM toggles) are only reachable there.
|
||||
.setPositiveButton(getString(R.string.settings_perm_manage)) { _, _ ->
|
||||
startActivity(Intent(this, SetupActivity::class.java).apply {
|
||||
// Review mode: leaving must return to playback, NOT restart pairing.
|
||||
putExtra(SetupActivity.EXTRA_MANAGE_ONLY, true)
|
||||
})
|
||||
}
|
||||
.setNeutralButton(getString(R.string.settings_perm_open)) { _, _ ->
|
||||
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = android.net.Uri.parse("package:$packageName")
|
||||
}
|
||||
|
|
@ -1264,8 +1512,57 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing in the Android lifecycle pauses a WebView, so a YouTube embed kept playing with the
|
||||
* app in the background and the panel kept making noise with the app "closed". Reported on
|
||||
* #234. onStop (not onPause) is the right hook: onPause also fires for a transient dialog or a
|
||||
* permission prompt, and pausing playback for those would be a visible stutter on a wall.
|
||||
*/
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (::mediaPlayer.isInitialized) mediaPlayer.onAppBackgrounded()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (::mediaPlayer.isInitialized) mediaPlayer.onAppForegrounded()
|
||||
|
||||
// Clear the boot "Starting display…" prompt EVERY time the player becomes visible, not
|
||||
// just in onCreate.
|
||||
//
|
||||
// Relauncher launches the activity directly when the overlay permission is granted — the
|
||||
// normal kiosk setup — and THEN posts the notification, deliberately, so a device that
|
||||
// could not auto-launch still has a tappable way back. On a device where the launch DID
|
||||
// work, that ordering means the prompt is posted after onCreate already cancelled it, and
|
||||
// nothing clears it again: a permanent "Starting display…" banner over content that is
|
||||
// already playing. Reported from the field with a photo of exactly that.
|
||||
//
|
||||
// If the player is on screen, the prompt is stale by definition — so clearing it here is
|
||||
// correct regardless of who posted it or when.
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager)?.cancel(999)
|
||||
|
||||
// Re-enter kiosk if that is how the operator left it. Done in onStart rather than onCreate
|
||||
// because lock-task can be dropped by the system on some transitions, and a panel that
|
||||
// quietly stopped being locked is the failure people notice only when someone walks out of
|
||||
// the app.
|
||||
restoreKioskMode()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
remoteStreaming = false
|
||||
// Everything below this line exists for the same reason the wall/group shutdown does, and
|
||||
// was missing: these Handlers are on the MAIN LOOPER, which outlives the Activity.
|
||||
//
|
||||
// PlaylistController kept advancing after the Activity was destroyed. Each tick wrote the
|
||||
// resume index and emitted play_start/play_end through the still-live WebSocketService, so
|
||||
// after a relaunch (the "launch" command, Relauncher after OTA/boot, a re-pair, or a config
|
||||
// change outside the ones we handle) TWO controllers were reporting playback for one screen
|
||||
// — inflating Total Plays and Hours in Reports, and racing over the resume position that
|
||||
// #234 relies on. Widget items also re-entered showWidget on a WebView nobody owned.
|
||||
if (::playlistController.isInitialized) playlistController.stop()
|
||||
if (::updateChecker.isInitialized) updateChecker.shutdown()
|
||||
// The 30s failure-check loop and anything else this Activity posted.
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
// Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper
|
||||
// (outlives this Activity), so a surviving tick would keep broadcasting sync frames against
|
||||
// the released player forever — the zombie-leader / split-brain / garbage-position leak.
|
||||
|
|
@ -1288,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
|
||||
|
|
|
|||
|
|
@ -288,6 +288,13 @@ class ProvisioningActivity : AppCompatActivity() {
|
|||
|
||||
wsService?.onPaired = { deviceId, name ->
|
||||
runOnUiThread {
|
||||
// ONE-SHOT. This callback's only job is the hand-off to MainActivity, but it was
|
||||
// being left installed on a service that OUTLIVES this Activity — and the server
|
||||
// sends device:paired on EVERY register, not just the first. So each register
|
||||
// relaunched MainActivity with CLEAR_TASK, which re-registered, which paired again:
|
||||
// a self-sustaining loop, ~1.3 relaunches/second measured on Android 12. On 12+ every
|
||||
// launch draws a splash screen, which is the "white screen flashing" users report.
|
||||
clearServiceCallbacks()
|
||||
cancelStuckTimer()
|
||||
stopRepairTicker()
|
||||
statusText.text = "Paired as: $name"
|
||||
|
|
@ -298,6 +305,19 @@ class ProvisioningActivity : AppCompatActivity() {
|
|||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the callbacks this Activity installed on the long-lived service. MainActivity never
|
||||
* assigns onRegistered/onUnpaired/onPaired, so nothing else would ever overwrite them — they
|
||||
* would keep firing into a destroyed Activity for the life of the process (and keep it alive).
|
||||
*/
|
||||
private fun clearServiceCallbacks() {
|
||||
try {
|
||||
wsService?.onPaired = null
|
||||
wsService?.onUnpaired = null
|
||||
wsService?.onRegistered = null
|
||||
} catch (_: Throwable) { }
|
||||
|
||||
// Re-pair path: the socket is usually already up (service kept running). Make sure it's
|
||||
// connecting, then render any pairing code the service already issued (race-free). If we're
|
||||
|
|
@ -315,6 +335,10 @@ class ProvisioningActivity : AppCompatActivity() {
|
|||
override fun onDestroy() {
|
||||
cancelStuckTimer()
|
||||
stopRepairTicker()
|
||||
// Before unbinding: the service keeps running, so a callback left pointing here would both
|
||||
// leak this Activity and keep firing. Matters when pairing never completes and the user
|
||||
// backs out, where the one-shot clear above never runs.
|
||||
clearServiceCallbacks()
|
||||
if (bound) {
|
||||
unbindService(connection)
|
||||
bound = false
|
||||
|
|
|
|||
|
|
@ -40,13 +40,29 @@ class SetupActivity : AppCompatActivity() {
|
|||
private lateinit var enableWriteSettingsBtn: Button
|
||||
private lateinit var continueBtn: Button
|
||||
|
||||
/**
|
||||
* Opened from the in-service Settings menu to REVIEW permissions, not as first-run setup.
|
||||
*
|
||||
* The difference matters: proceedToNext() always goes to ProvisioningActivity, so without this
|
||||
* a paired, playing screen would be sent to the pairing page by the button it was told to press.
|
||||
* In manage mode the screen simply returns to the player.
|
||||
*/
|
||||
private val manageOnly: Boolean get() = intent?.getBooleanExtra(EXTRA_MANAGE_ONLY, false) == true
|
||||
|
||||
companion object {
|
||||
const val EXTRA_MANAGE_ONLY = "EXTRA_MANAGE_ONLY"
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Skip setup if already completed
|
||||
// Skip setup if already completed — but NOT when we were opened deliberately to review
|
||||
// permissions from the in-service Settings menu. That is the whole point of manage mode:
|
||||
// every device that can reach it has setup_complete set, so without this exemption the
|
||||
// screen closes before it draws and the menu entry appears to do nothing.
|
||||
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
|
||||
if (prefs.getBoolean("setup_complete", false)) {
|
||||
if (!manageOnly && prefs.getBoolean("setup_complete", false)) {
|
||||
proceedToNext()
|
||||
return
|
||||
}
|
||||
|
|
@ -56,7 +72,7 @@ class SetupActivity : AppCompatActivity() {
|
|||
// moot — so skip the entire manual first-run wizard. Accessibility stays optional (it can't
|
||||
// be auto-enabled). Guarded on ownership, so a NORMAL install still gets the full wizard.
|
||||
val ownerPolicy = com.remotedisplay.player.admin.STPolicy(this)
|
||||
if (ownerPolicy.isDeviceOwner()) {
|
||||
if (!manageOnly && ownerPolicy.isDeviceOwner()) {
|
||||
ownerPolicy.applyOnboardingPolicy()
|
||||
prefs.edit().putBoolean("setup_complete", true).apply()
|
||||
// Remote control needs the accessibility service, and it's the one thing no policy can
|
||||
|
|
@ -98,9 +114,22 @@ class SetupActivity : AppCompatActivity() {
|
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
findViewById<View>(R.id.notificationRow).visibility = View.VISIBLE
|
||||
findViewById<Button>(R.id.enableNotificationBtn).setOnClickListener {
|
||||
ActivityCompat.requestPermissions(
|
||||
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100
|
||||
)
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
this, Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
// requestPermissions() does nothing once the answer is already given, so it
|
||||
// cannot be the way back. App notification settings can toggle it either way.
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
})
|
||||
} catch (_: Exception) { openAppSettings() }
|
||||
} else {
|
||||
ActivityCompat.requestPermissions(
|
||||
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +176,18 @@ class SetupActivity : AppCompatActivity() {
|
|||
// Default launcher / HOME: a kiosk MUST be the default launcher, else Android returns to the
|
||||
// stock launcher and tears down + recreates the player on a loop (it never renders). Request
|
||||
// the HOME role (clean system dialog on API 29+); fall back to the Home-app picker in Settings.
|
||||
// OPTIONAL: location, solely so the device page can show the Wi-Fi network name. Requested
|
||||
// only when someone taps this row — never at startup, and nothing else in the player depends
|
||||
// on it. Once granted (or permanently denied) requestPermissions() stops prompting, so an
|
||||
// already-answered row sends you to app settings where it can be changed either way.
|
||||
findViewById<Button>(R.id.enableLocationBtn).setOnClickListener {
|
||||
if (hasLocationPermission()) openAppSettings()
|
||||
else ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
|
||||
101
|
||||
)
|
||||
}
|
||||
findViewById<Button>(R.id.enableLauncherBtn).setOnClickListener { promptSetDefaultLauncher() }
|
||||
|
||||
// Launch-on-boot needs USE_FULL_SCREEN_INTENT, which Android 14+ auto-revokes
|
||||
|
|
@ -173,17 +214,35 @@ class SetupActivity : AppCompatActivity() {
|
|||
// Battery-optimization exemption keeps the boot receiver from being deferred
|
||||
// and the app from being killed in standby (esp. on OEM / TV boxes).
|
||||
enableBatteryBtn.setOnClickListener {
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
val exempt = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.isIgnoringBatteryOptimizations(packageName)
|
||||
if (exempt) {
|
||||
// ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS only ASKS to add an exemption — it
|
||||
// offers no way to remove one, so it is a dead end for someone already exempt.
|
||||
// The system list is where an exemption can actually be turned back off.
|
||||
try { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) }
|
||||
catch (_: Exception) { openAppSettings() }
|
||||
} else {
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
try { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) }
|
||||
catch (_: Exception) { openAppSettings() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manageOnly) {
|
||||
// "Continue anyway" and the skip hint are first-run language; here the only action is
|
||||
// to go back to what was already playing.
|
||||
continueBtn.text = getString(R.string.settings_perm_done)
|
||||
findViewById<TextView>(R.id.skipText).visibility = View.GONE
|
||||
}
|
||||
|
||||
continueBtn.setOnClickListener {
|
||||
prefs.edit().putBoolean("setup_complete", true).apply()
|
||||
if (!manageOnly) prefs.edit().putBoolean("setup_complete", true).apply()
|
||||
proceedToNext()
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +259,30 @@ class SetupActivity : AppCompatActivity() {
|
|||
updateStatuses()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a permission row's button. Granted rows used to set the button GONE, which left the
|
||||
* choice one-way: every permission on this screen is granted in system Settings and the app
|
||||
* cannot revoke any of them itself, so hiding the only route to that screen meant there was no
|
||||
* way back. Reported on #234 — "if I make the app as Home launcher but later on want to remove
|
||||
* it then how can I do it?".
|
||||
*
|
||||
* The button now stays put and relabels. Same tap target, same destination; the label is honest
|
||||
* that Settings is where the change happens rather than promising we can revoke it ourselves.
|
||||
*/
|
||||
private fun bindPermissionButton(btn: Button, granted: Boolean, enableLabel: String) {
|
||||
btn.visibility = View.VISIBLE
|
||||
btn.text = if (granted) "Manage" else enableLabel
|
||||
}
|
||||
|
||||
/** Last-resort destination: this app's own settings page, where everything can be reached. */
|
||||
private fun openAppSettings() {
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
})
|
||||
} catch (_: Exception) { try { startActivity(Intent(Settings.ACTION_SETTINGS)) } catch (_: Exception) {} }
|
||||
}
|
||||
|
||||
private fun updateStatuses() {
|
||||
// Accessibility
|
||||
val accessibilityEnabled = isAccessibilityEnabled()
|
||||
|
|
@ -207,7 +290,7 @@ class SetupActivity : AppCompatActivity() {
|
|||
accessibilityStatus.setTextColor(
|
||||
if (accessibilityEnabled) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
|
||||
)
|
||||
enableAccessibilityBtn.visibility = if (accessibilityEnabled) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(enableAccessibilityBtn, accessibilityEnabled, "Enable")
|
||||
|
||||
// Install unknown apps
|
||||
val canInstall = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
|
@ -217,7 +300,7 @@ class SetupActivity : AppCompatActivity() {
|
|||
installStatus.setTextColor(
|
||||
if (canInstall) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
|
||||
)
|
||||
enableInstallBtn.visibility = if (canInstall) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(enableInstallBtn, canInstall, "Enable")
|
||||
|
||||
// Notifications (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
|
|
@ -228,8 +311,7 @@ class SetupActivity : AppCompatActivity() {
|
|||
notificationStatus.setTextColor(
|
||||
if (hasNotif) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
|
||||
)
|
||||
findViewById<Button>(R.id.enableNotificationBtn).visibility =
|
||||
if (hasNotif) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(findViewById(R.id.enableNotificationBtn), hasNotif, "Enable")
|
||||
}
|
||||
|
||||
// Launch on boot (full-screen intent — only restrictable on Android 14+)
|
||||
|
|
@ -237,7 +319,7 @@ class SetupActivity : AppCompatActivity() {
|
|||
val canFsi = getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
|
||||
fullscreenStatus.text = if (canFsi) "ON" else "OFF"
|
||||
fullscreenStatus.setTextColor(if (canFsi) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||
enableFullscreenBtn.visibility = if (canFsi) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(enableFullscreenBtn, canFsi, "Enable")
|
||||
}
|
||||
|
||||
// Battery optimization exemption
|
||||
|
|
@ -245,33 +327,66 @@ class SetupActivity : AppCompatActivity() {
|
|||
.isIgnoringBatteryOptimizations(packageName)
|
||||
batteryStatus.text = if (ignoringBattery) "ON" else "OFF"
|
||||
batteryStatus.setTextColor(if (ignoringBattery) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||
enableBatteryBtn.visibility = if (ignoringBattery) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(enableBatteryBtn, ignoringBattery, "Enable")
|
||||
|
||||
// Display over other apps
|
||||
val canOverlay = Settings.canDrawOverlays(this)
|
||||
overlayStatus.text = if (canOverlay) "ON" else "OFF"
|
||||
overlayStatus.setTextColor(if (canOverlay) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||
enableOverlayBtn.visibility = if (canOverlay) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(enableOverlayBtn, canOverlay, "Enable")
|
||||
|
||||
// #160 WRITE_SETTINGS (system brightness / screen-off timeout)
|
||||
val canWrite = Settings.System.canWrite(this)
|
||||
writeSettingsStatus.text = if (canWrite) "ON" else "OFF"
|
||||
writeSettingsStatus.setTextColor(if (canWrite) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||
enableWriteSettingsBtn.visibility = if (canWrite) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(enableWriteSettingsBtn, canWrite, "Enable")
|
||||
|
||||
// Optional Wi-Fi-name permission
|
||||
val hasLoc = hasLocationPermission()
|
||||
val locationStatus = findViewById<TextView>(R.id.locationStatus)
|
||||
locationStatus.text = if (hasLoc) "ON" else "OFF"
|
||||
locationStatus.setTextColor(if (hasLoc) 0xFF22C55E.toInt() else 0xFF64748B.toInt())
|
||||
bindPermissionButton(findViewById(R.id.enableLocationBtn), hasLoc, "Enable")
|
||||
|
||||
// Default launcher (HOME): kiosk foreground stability requires being the default launcher.
|
||||
val isDefaultHome = isDefaultLauncher()
|
||||
val launcherStatus = findViewById<TextView>(R.id.launcherStatus)
|
||||
launcherStatus.text = if (isDefaultHome) "ON" else "OFF"
|
||||
launcherStatus.setTextColor(if (isDefaultHome) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||
findViewById<Button>(R.id.enableLauncherBtn).visibility = if (isDefaultHome) View.GONE else View.VISIBLE
|
||||
bindPermissionButton(findViewById(R.id.enableLauncherBtn), isDefaultHome, "Set")
|
||||
|
||||
// Update continue button text
|
||||
val allGood = accessibilityEnabled && canInstall
|
||||
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"
|
||||
// updateStatuses() runs after onCreate's setup and re-labels this button every time, so the
|
||||
// manage-mode label has to be honoured HERE too — setting it once earlier was silently
|
||||
// overwritten. In review mode there is nothing to continue TO; the only action is going back.
|
||||
continueBtn.text = when {
|
||||
manageOnly -> getString(R.string.settings_perm_done)
|
||||
allGood -> "Continue to Setup"
|
||||
else -> "Continue Anyway"
|
||||
}
|
||||
}
|
||||
|
||||
/** Either location permission is enough for the SSID; coarse suffices below Android 10. */
|
||||
private fun hasLocationPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun isDefaultLauncher(): Boolean {
|
||||
// Ask the SAME authority the action uses. This used to read resolveActivity(MATCH_DEFAULT_ONLY),
|
||||
// which can name us when we are merely a HOME candidate rather than the chosen home app — so
|
||||
// the row could say ON while the system still had the OEM launcher as home, and the button
|
||||
// then opened the "become home" request dialog instead of the picker. Reported on #234:
|
||||
// "in the apk I have granted the permission ... BUT in the settings of the tablet it still
|
||||
// shows the tablet native launcher as home."
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
try {
|
||||
val rm = getSystemService(android.app.role.RoleManager::class.java)
|
||||
if (rm != null && rm.isRoleAvailable(android.app.role.RoleManager.ROLE_HOME)) {
|
||||
return rm.isRoleHeld(android.app.role.RoleManager.ROLE_HOME)
|
||||
}
|
||||
} catch (_: Exception) { /* fall through to the pre-Q check */ }
|
||||
}
|
||||
val ri = packageManager.resolveActivity(
|
||||
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
|
||||
PackageManager.MATCH_DEFAULT_ONLY
|
||||
|
|
@ -313,6 +428,8 @@ class SetupActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private fun proceedToNext() {
|
||||
// Reviewing permissions on a live screen must never restart pairing — just go back.
|
||||
if (manageOnly) { finish(); return }
|
||||
startActivity(Intent(this, ProvisioningActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,29 @@ import java.util.concurrent.TimeUnit
|
|||
* Root-2 caching fixes vs the "stuck downloading / frozen" bug:
|
||||
* - a hard OVERALL [callTimeout] so a slow-drip/stalled download on a HEALTHY socket can't hang
|
||||
* forever (the old client only had a per-read timeout, which a trickle never trips),
|
||||
* - download to a `.part` temp + integrity-check (Content-Length) via [CacheValidation] + atomic
|
||||
* rename, so a truncated/interrupted body is NEVER promoted to the cache and played as if whole,
|
||||
* - download to a `.part` temp + integrity-check via [CacheValidation] + atomic rename, so a
|
||||
* truncated/interrupted body is NEVER promoted to the cache and played as if whole,
|
||||
* - exact-prefix cache lookup that also excludes in-flight `.part` files.
|
||||
*
|
||||
* RESUME. Those fixes made a bad download safe; they did not make it possible. Every attempt began
|
||||
* at byte 0 and the `.part` was deleted on failure, so on a link that cannot carry a whole asset in
|
||||
* one unbroken call — a one-bar 5G site, the case this was reported from — the file NEVER lands.
|
||||
* The player then has nothing cached, and a screen with nothing cached shows the waiting state:
|
||||
* reported as "the screens go black instead of playing from cache", when the real failure was that
|
||||
* the cache could never be filled in the first place. Five minutes of transfer, discarded; back
|
||||
* off; five more minutes, discarded; forever.
|
||||
*
|
||||
* So an interrupted download now KEEPS its `.part` and the next attempt asks for the rest with a
|
||||
* Range header. Progress accumulates across attempts and across reboots instead of being thrown
|
||||
* away, which is the whole difference between "eventually plays" and "never plays".
|
||||
*
|
||||
* Two ways a resume could corrupt the cache, both closed:
|
||||
* - the asset changed under us — `If-Range` with the stored validator makes the server answer 200
|
||||
* with the whole body instead of a tail, and we restart from zero.
|
||||
* - the `.part` is longer than the asset — the server answers 416 and we discard it.
|
||||
* The completeness check is unchanged in spirit but now counts TOTAL bytes on disk against the
|
||||
* total the server declared in Content-Range, not bytes received this attempt.
|
||||
*
|
||||
* The primary constructor takes the cache dir + client directly so the real download logic is
|
||||
* unit-testable (see ContentDownloadTest) against a local server without an Android Context; the
|
||||
* [Context] convenience constructor is what the app uses.
|
||||
|
|
@ -29,12 +48,27 @@ class ContentCache internal constructor(
|
|||
defaultClient()
|
||||
)
|
||||
|
||||
/**
|
||||
* What one download attempt achieved. The distinction that matters is [Partial] vs [Failed]:
|
||||
* an attempt that moved bytes onto disk is PROGRESS, and backing that off exponentially the way
|
||||
* a hard failure is backed off is what turns a slow site into a dead one.
|
||||
*/
|
||||
sealed class Result {
|
||||
data class Done(val file: File) : Result()
|
||||
/** Bytes are on disk and the next attempt resumes from there. */
|
||||
data class Partial(val bytesOnDisk: Long, val totalBytes: Long, val progressed: Boolean) : Result()
|
||||
/** Nothing usable happened: refused, unreachable, or a stale partial we had to discard. */
|
||||
object Failed : Result()
|
||||
}
|
||||
|
||||
fun getCachedFile(contentId: String): File? {
|
||||
// Match "<id>.<ext>" exactly: the trailing dot stops an id that PREFIXES another id from
|
||||
// cross-matching, and `.part` temps (partial/in-flight downloads) are never returned.
|
||||
val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.endsWith(PART_SUFFIX) }
|
||||
// cross-matching. `contains` rather than `endsWith` for the temp check because the resume
|
||||
// validator sidecar is "<id>.<ext>.part.tag" — it does not END with ".part", and returning
|
||||
// THAT as the cached asset would hand the player a short ETag file to play.
|
||||
val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.contains(PART_SUFFIX) && !name.endsWith(REV_SUFFIX) }
|
||||
val hit = files?.firstOrNull()?.takeIf { it.exists() && it.length() > 0 }
|
||||
com.remotedisplay.player.util.DebugLog.v("ContentCache", "getCachedFile($contentId): dir=${cacheDir.absolutePath} listFiles=${files?.size} -> ${hit?.name ?: "MISS"}")
|
||||
com.remotedisplay.player.util.DebugLog.v("ContentCache", "getCachedFile($contentId): dir=${cacheDir.absolutePath} listFiles=${files?.size ?: -1} hit=${hit?.name}")
|
||||
return hit
|
||||
}
|
||||
|
||||
|
|
@ -42,61 +76,172 @@ class ContentCache internal constructor(
|
|||
return getCachedFile(contentId) != null
|
||||
}
|
||||
|
||||
fun downloadContent(serverUrl: String, contentId: String, filename: String): File? {
|
||||
/**
|
||||
* Cached AND holding the revision the playlist is asking for.
|
||||
*
|
||||
* The dashboard can replace an asset's bytes under a stable content id, which is the one way a
|
||||
* cached copy can be permanently wrong: the id does not change, the filename does not change,
|
||||
* and nothing about a plain existence check can tell. A panel would keep playing last month's
|
||||
* video until somebody deleted and re-added the item. Comparing the revision is what makes
|
||||
* "cached for offline" compatible with "and it still updates".
|
||||
*
|
||||
* A revision of 0 means the server never sent one (an older build): fall back to existence, so
|
||||
* an upgrade does not re-download the entire playlist over the link least able to afford it.
|
||||
*/
|
||||
fun isContentCached(contentId: String, rev: Long): Boolean {
|
||||
val file = getCachedFile(contentId) ?: return false
|
||||
if (rev <= 0L) return true
|
||||
return readRev(file) == rev
|
||||
}
|
||||
|
||||
private fun revFile(file: File) = File(file.absolutePath + REV_SUFFIX)
|
||||
|
||||
private fun readRev(file: File): Long =
|
||||
try { revFile(file).takeIf { it.exists() }?.readText()?.trim()?.toLongOrNull() ?: 0L }
|
||||
catch (e: Exception) { 0L }
|
||||
|
||||
/**
|
||||
* Fetch (or continue fetching) [contentId]. Safe to call repeatedly: each call transfers what
|
||||
* the link allows and leaves the rest for the next one.
|
||||
*/
|
||||
fun fetch(serverUrl: String, contentId: String, filename: String, rev: Long = 0L): Result {
|
||||
val ext = filename.substringAfterLast('.', "mp4")
|
||||
val finalFile = File(cacheDir, "${contentId}.${ext}")
|
||||
val partFile = File(cacheDir, "${contentId}.${ext}${PART_SUFFIX}")
|
||||
val finalFile = File(cacheDir, "$contentId.$ext")
|
||||
val partFile = File(cacheDir, "$contentId.$ext$PART_SUFFIX")
|
||||
val tagFile = File(cacheDir, "$contentId.$ext$PART_SUFFIX$TAG_SUFFIX")
|
||||
|
||||
// Only resume when we also hold the validator that was current when those bytes were
|
||||
// fetched. Without it there is no way to know the asset is still the same one, and a silent
|
||||
// splice of two files is worse than re-downloading.
|
||||
val validator = readValidator(tagFile)
|
||||
val resumeFrom = if (validator != null && partFile.exists()) partFile.length() else 0L
|
||||
if (resumeFrom == 0L) { partFile.delete(); tagFile.delete() }
|
||||
|
||||
try {
|
||||
val url = "${serverUrl}/api/content/${contentId}/file"
|
||||
val request = Request.Builder().url(url).build()
|
||||
// The revision rides in the URL as well as in the sidecar: an intermediary caching
|
||||
// /api/content/<id>/file would otherwise happily serve the superseded bytes to every
|
||||
// panel behind it, and no amount of client-side bookkeeping could tell.
|
||||
val url = "$serverUrl/api/content/$contentId/file" + (if (rev > 0L) "?rev=$rev" else "")
|
||||
val builder = Request.Builder().url(url)
|
||||
if (resumeFrom > 0) {
|
||||
builder.header("Range", "bytes=$resumeFrom-")
|
||||
builder.header("If-Range", validator!!)
|
||||
}
|
||||
|
||||
// .use closes the Response (and its body) on every path — also fixes the prior
|
||||
// error-path body/connection leak.
|
||||
client.newCall(request).execute().use { response ->
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
// Our partial is at or past the end of the asset: it belongs to something else, or
|
||||
// to a truncated earlier life of this file. Discard and start clean next time.
|
||||
if (response.code == 416) {
|
||||
Log.w("ContentCache", "Server refused resume at $resumeFrom for $filename (416) — discarding stale partial")
|
||||
partFile.delete(); tagFile.delete()
|
||||
return Result.Failed
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
Log.e("ContentCache", "Download failed: ${response.code}")
|
||||
return null
|
||||
// The partial is kept: a 5xx or a captive-portal interception says nothing about
|
||||
// the bytes we already hold.
|
||||
return if (resumeFrom > 0) Result.Partial(resumeFrom, -1L, false) else Result.Failed
|
||||
}
|
||||
// We issue a plain (no-Range) GET, so a 206 Partial Content means a proxy/CDN
|
||||
// returned a PARTIAL body whose Content-Length matches that partial — which would
|
||||
// pass the byte-count integrity check and promote a truncated file. Require a full 200.
|
||||
val body = response.body ?: return Result.Failed
|
||||
|
||||
val appendAt: Long
|
||||
val total: Long
|
||||
if (response.code == 206) {
|
||||
Log.e("ContentCache", "Refusing 206 Partial Content for $filename — not a complete file")
|
||||
return null
|
||||
}
|
||||
partFile.delete() // clear any earlier partial before writing
|
||||
val body = response.body ?: return null
|
||||
val expected = body.contentLength() // -1 when unknown (chunked)
|
||||
var written = 0L
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(partFile).use { output -> written = input.copyTo(output) }
|
||||
}
|
||||
// Root-2: a truncated body must NOT be promoted to the cache and played as whole.
|
||||
if (!CacheValidation.isComplete(written, expected)) {
|
||||
Log.e("ContentCache", "Incomplete download ($written/$expected bytes) for $filename — discarding partial")
|
||||
// Content-Length on a 206 is the length of the CHUNK, so the only trustworthy
|
||||
// source for the full size is the total in Content-Range.
|
||||
val range = parseContentRange(response.header("Content-Range"))
|
||||
if (range == null || range.first != resumeFrom || range.second <= 0L) {
|
||||
// A 206 we cannot verify, or one starting somewhere we did not ask for.
|
||||
// Appending it blind would corrupt the file at exactly the byte count that
|
||||
// makes it look complete.
|
||||
Log.e("ContentCache", "Unusable 206 for $filename (Content-Range=${response.header("Content-Range")}, wanted $resumeFrom) — restarting")
|
||||
partFile.delete(); tagFile.delete()
|
||||
return Result.Failed
|
||||
}
|
||||
appendAt = resumeFrom
|
||||
total = range.second
|
||||
if (!tagFile.exists()) writeValidator(tagFile, response.header("ETag") ?: response.header("Last-Modified"))
|
||||
} else {
|
||||
// 200. Either we sent no Range, or If-Range told the server the asset changed
|
||||
// and it sent the whole thing instead of a tail. Both mean: start from zero.
|
||||
if (resumeFrom > 0) Log.i("ContentCache", "Asset changed under a resume for $filename — restarting from 0")
|
||||
appendAt = 0L
|
||||
partFile.delete()
|
||||
return null
|
||||
total = body.contentLength() // -1 when unknown (chunked)
|
||||
writeValidator(tagFile, response.header("ETag") ?: response.header("Last-Modified"))
|
||||
}
|
||||
|
||||
var onDisk = appendAt
|
||||
try {
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(partFile, appendAt > 0).use { output ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
output.write(buf, 0, n)
|
||||
onDisk += n
|
||||
}
|
||||
output.flush()
|
||||
// Durable, so a power cut on a signage panel costs the last buffer
|
||||
// rather than the whole partial. These are big files on bad links; the
|
||||
// sync is cheap next to re-fetching 200MB.
|
||||
try { output.fd.sync() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// The break we now RECOVER from instead of restarting after. Includes the read
|
||||
// timeout (a stalled stream) and the call timeout (a slow drip that ran out of
|
||||
// its attempt budget) — on a bad link these are the normal case, not the
|
||||
// exception, and everything written so far stays.
|
||||
Log.i("ContentCache", "Download interrupted for $filename at ${partFile.length()} bytes (${e.message})")
|
||||
return partialOrDiscard(partFile, tagFile, partFile.length(), total, resumeFrom)
|
||||
}
|
||||
|
||||
// Total bytes on disk against the declared total — NOT bytes received this attempt,
|
||||
// which on a resume is only the tail.
|
||||
if (!CacheValidation.isComplete(onDisk, total)) {
|
||||
Log.i("ContentCache", "Incomplete after this attempt ($onDisk/$total) for $filename")
|
||||
return partialOrDiscard(partFile, tagFile, onDisk, total, resumeFrom)
|
||||
}
|
||||
|
||||
finalFile.delete()
|
||||
if (!partFile.renameTo(finalFile)) {
|
||||
Log.e("ContentCache", "Rename failed for $filename")
|
||||
partFile.delete()
|
||||
return null
|
||||
partFile.delete(); tagFile.delete()
|
||||
return Result.Failed
|
||||
}
|
||||
Log.i("ContentCache", "Downloaded: $filename -> ${finalFile.absolutePath} ($written bytes)")
|
||||
return finalFile
|
||||
tagFile.delete()
|
||||
// Record WHICH revision these bytes are, so a later replace is detectable. Written
|
||||
// after the rename: a revision marker next to a file that is not there yet would
|
||||
// claim a cached asset that does not exist.
|
||||
try {
|
||||
if (rev > 0L) revFile(finalFile).writeText(rev.toString()) else revFile(finalFile).delete()
|
||||
} catch (e: Exception) { /* the file is still usable; worst case it re-downloads */ }
|
||||
Log.i("ContentCache", "Downloaded: $filename -> ${finalFile.absolutePath} ($onDisk bytes)")
|
||||
return Result.Done(finalFile)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Includes callTimeout / readTimeout (a stalled download on a healthy socket) and any
|
||||
// mid-stream break — never leave a partial at the real path.
|
||||
// Connect-time failures (no route, DNS, TLS) — nothing was transferred, so whatever is
|
||||
// already on disk is still valid to resume from. This also catches a throw from closing
|
||||
// the response after a partial body, which is why `progressed` is measured against the
|
||||
// bytes on disk rather than assumed false: an attempt that advanced must not be
|
||||
// reported as a stall just because the connection objected on the way out.
|
||||
Log.e("ContentCache", "Download error: ${e.message}")
|
||||
partFile.delete()
|
||||
return null
|
||||
val kept = partFile.length()
|
||||
return if (kept > 0) partialOrDiscard(partFile, tagFile, kept, -1L, resumeFrom) else Result.Failed
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete-or-nothing wrapper: returns the cached file only when the asset is whole. */
|
||||
fun downloadContent(serverUrl: String, contentId: String, filename: String, rev: Long = 0L): File? =
|
||||
(fetch(serverUrl, contentId, filename, rev) as? Result.Done)?.file
|
||||
|
||||
fun deleteContent(contentId: String) {
|
||||
// Exact-prefix (with the dot) so we don't delete a different id's file — and this also
|
||||
// sweeps the "<id>.<ext>.part" temp.
|
||||
// sweeps the "<id>.<ext>.part" temp and its ".part.tag" validator.
|
||||
cacheDir.listFiles { _, name -> name.startsWith("$contentId.") }?.forEach { it.delete() }
|
||||
Log.i("ContentCache", "Deleted cached content: $contentId")
|
||||
}
|
||||
|
|
@ -109,14 +254,56 @@ class ContentCache internal constructor(
|
|||
return cacheDir.listFiles()?.sumOf { it.length() } ?: 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an unfinished attempt — keeping the bytes only if the NEXT attempt can build on them.
|
||||
*
|
||||
* Without a validator there is no safe resume, so the partial is dead weight: the next attempt
|
||||
* would restart from zero, re-fetch the same prefix, and land in exactly the same place. Worse,
|
||||
* counting that as progress would make the coordinator chain attempts against a link that is
|
||||
* getting nowhere. Discard it and report no progress, so it backs off like the failure it is.
|
||||
*/
|
||||
private fun partialOrDiscard(partFile: File, tagFile: File, onDisk: Long, total: Long, resumeFrom: Long): Result {
|
||||
if (!tagFile.exists()) {
|
||||
partFile.delete()
|
||||
return Result.Partial(0L, total, false)
|
||||
}
|
||||
return Result.Partial(onDisk, total, onDisk > resumeFrom)
|
||||
}
|
||||
|
||||
private fun readValidator(tagFile: File): String? =
|
||||
try { if (tagFile.exists()) tagFile.readText().trim().ifEmpty { null } else null } catch (_: Exception) { null }
|
||||
|
||||
private fun writeValidator(tagFile: File, value: String?) {
|
||||
// No validator (a server that sends neither ETag nor Last-Modified) means no safe resume:
|
||||
// leave the sidecar absent and the next attempt starts over rather than splicing blind.
|
||||
try { if (value.isNullOrBlank()) tagFile.delete() else tagFile.writeText(value) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PART_SUFFIX = ".part"
|
||||
private const val TAG_SUFFIX = ".tag"
|
||||
private const val REV_SUFFIX = ".rev"
|
||||
|
||||
/**
|
||||
* "bytes <start>-<end>/<total>" -> (start, total). Null for anything else, including the
|
||||
* "*" total a server may send, which gives us nothing to validate completeness against.
|
||||
*/
|
||||
internal fun parseContentRange(header: String?): Pair<Long, Long>? {
|
||||
val m = Regex("""^\s*bytes\s+(\d+)-(\d+)/(\d+)\s*$""").find(header ?: return null) ?: return null
|
||||
val start = m.groupValues[1].toLongOrNull() ?: return null
|
||||
val total = m.groupValues[3].toLongOrNull() ?: return null
|
||||
return start to total
|
||||
}
|
||||
|
||||
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS) // Root-2: a stalled stream (no bytes 30s) aborts (was 5min)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(5, TimeUnit.MINUTES) // Root-2: hard OVERALL cap so a slow-drip can't hang forever
|
||||
// Root-2 gave this a hard OVERALL cap so a slow drip could not hang forever. With resume
|
||||
// it caps ONE ATTEMPT rather than the whole asset: a link that only manages 20MB per
|
||||
// call now keeps those 20MB and continues, where before the cap was the reason a large
|
||||
// file could never finish.
|
||||
.callTimeout(5, TimeUnit.MINUTES)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,32 +49,67 @@ class DownloadCoordinator(
|
|||
* sweep — the 60s refresh and every post-reconnect re-register included. Idempotent and
|
||||
* non-blocking: it enqueues at most ONE download per contentId and returns immediately.
|
||||
*/
|
||||
fun ensure(contentId: String, filename: String) {
|
||||
fun ensure(contentId: String, filename: String, rev: Long = 0L) {
|
||||
if (contentId.isEmpty()) return
|
||||
if (cache.isContentCached(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): SEED-A cached -> ack ready"); onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A)
|
||||
if (cache.isContentCached(contentId, rev)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): SEED-A cached -> ack ready"); onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A)
|
||||
// Socket down => the WATCHDOG owns recovery; don't hammer downloads over a dead connection.
|
||||
if (!socketAlive()) { DebugLog.v("DownloadCoordinator", "ensure($contentId): socket not alive -> skip"); return }
|
||||
if (now() < (nextAttemptAt[contentId] ?: 0L)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): in backoff until ${nextAttemptAt[contentId]} -> skip"); return } // in failure backoff — don't storm
|
||||
if (!inFlight.add(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): already inFlight -> skip"); return } // single-flight: already downloading
|
||||
DebugLog.v("DownloadCoordinator", "ensure($contentId): dispatching download '$filename'")
|
||||
try {
|
||||
executor.execute { runDownload(contentId, filename) }
|
||||
executor.execute { runDownload(contentId, filename, rev) }
|
||||
} catch (e: Throwable) {
|
||||
inFlight.remove(contentId) // executor rejected (shut down) — don't leak the guard
|
||||
}
|
||||
}
|
||||
|
||||
private fun runDownload(contentId: String, filename: String) {
|
||||
/**
|
||||
* Run attempts back-to-back for as long as each one is putting bytes on disk.
|
||||
*
|
||||
* A site whose link only carries part of an asset per call needs many attempts to finish one
|
||||
* file. Handing each attempt back to the 60s playlist sweep would stretch a 200MB video over
|
||||
* hours of mostly-idle waiting, and running to the exponential backoff would stretch it to
|
||||
* never — which is the failure this whole change is about. Progress is the signal: keep going
|
||||
* while bytes are landing, stop the moment one attempt achieves nothing.
|
||||
*
|
||||
* inFlight is held across the whole chain, so this is still exactly one writer per `.part` and
|
||||
* a concurrent sweep still finds the item busy rather than starting a duplicate.
|
||||
*/
|
||||
private fun runDownload(contentId: String, filename: String, rev: Long) {
|
||||
try {
|
||||
val file = cache.downloadContent(serverUrl(), contentId, filename)
|
||||
if (file != null) {
|
||||
attempts.remove(contentId); nextAttemptAt.remove(contentId)
|
||||
// Ack only reaches a live socket; if it dropped, the reconnect's re-register clears
|
||||
// the ack set and the next sweep re-acks the now-cached file.
|
||||
if (socketAlive()) onAck(contentId, "ready")
|
||||
} else {
|
||||
onFailure(contentId) // includes a reconnect-truncated .part (ContentCache returned null)
|
||||
var link = 0
|
||||
while (link < MAX_RESUME_CHAIN) {
|
||||
link++
|
||||
val result = cache.fetch(serverUrl(), contentId, filename, rev)
|
||||
when (result) {
|
||||
is ContentCache.Result.Done -> {
|
||||
attempts.remove(contentId); nextAttemptAt.remove(contentId)
|
||||
// Ack only reaches a live socket; if it dropped, the reconnect's re-register
|
||||
// clears the ack set and the next sweep re-acks the now-cached file.
|
||||
if (socketAlive()) onAck(contentId, "ready")
|
||||
return
|
||||
}
|
||||
is ContentCache.Result.Partial -> {
|
||||
if (!result.progressed) {
|
||||
// Bytes are held but this attempt added none: the link is down rather
|
||||
// than slow, so back off properly instead of spinning on it.
|
||||
onFailure(contentId)
|
||||
return
|
||||
}
|
||||
DebugLog.v("DownloadCoordinator", "resume $contentId: ${result.bytesOnDisk}/${result.totalBytes} after attempt $link")
|
||||
// Deliberately NOT acked as failed. A download that is advancing is not a
|
||||
// failure, and telling the CMS otherwise is what put items in the dashboard
|
||||
// showing "failed" while they were in fact still arriving.
|
||||
}
|
||||
is ContentCache.Result.Failed -> { onFailure(contentId); return }
|
||||
}
|
||||
}
|
||||
// Still going when the chain ran out. Not a failure — hand it back to the next sweep
|
||||
// with a short, FIXED delay rather than the exponential one, so a large asset over a
|
||||
// slow link keeps advancing instead of decaying into the 5-minute cap.
|
||||
nextAttemptAt[contentId] = now() + RESUME_HANDBACK_MS
|
||||
DebugLog.v("DownloadCoordinator", "resume chain exhausted for $contentId — continuing on the next sweep")
|
||||
} catch (e: Throwable) {
|
||||
Log.w("DownloadCoordinator", "download $contentId failed: ${e.message}")
|
||||
onFailure(contentId)
|
||||
|
|
@ -125,5 +160,13 @@ class DownloadCoordinator(
|
|||
const val MAX_CONCURRENT = 3
|
||||
const val BACKOFF_BASE_MS = 15_000L
|
||||
const val BACKOFF_MAX_MS = 5 * 60_000L
|
||||
/**
|
||||
* Consecutive resuming attempts before the item goes back on the sweep. Bounded so one
|
||||
* enormous asset on a slow link cannot hold an executor slot indefinitely and starve the
|
||||
* other two items in a playlist — with a 5-minute attempt cap this is still up to an hour
|
||||
* of continuous transfer per dispatch.
|
||||
*/
|
||||
const val MAX_RESUME_CHAIN = 12
|
||||
const val RESUME_HANDBACK_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,17 @@ class ServerConfig(context: Context) {
|
|||
get() = prefs.getString("cached_playlist", "") ?: ""
|
||||
set(value) = prefs.edit().putString("cached_playlist", value).apply()
|
||||
|
||||
// #234: last playing index + when it started. Lives here, not in PlaylistController, precisely
|
||||
// because the controller is rebuilt with every Activity — which is how a relaunch used to reset
|
||||
// playback to the first item and starve everything after it.
|
||||
var resumeIndex: Int
|
||||
get() = prefs.getInt("resume_index", -1)
|
||||
set(value) = prefs.edit().putInt("resume_index", value).apply()
|
||||
|
||||
var resumeAt: Long
|
||||
get() = prefs.getLong("resume_at", 0L)
|
||||
set(value) = prefs.edit().putLong("resume_at", value).apply()
|
||||
|
||||
fun clearPlaylistCache() {
|
||||
prefs.edit().remove("cached_playlist").apply()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ class MediaPlayerManager(
|
|||
// Plain image mount (visibility flip + set bitmap). Shared by the transition-done swap and the
|
||||
// no-transition hard cut.
|
||||
private fun mountImageBitmap(bitmap: Bitmap) {
|
||||
mountGeneration++
|
||||
stopYoutubeIfPlaying()
|
||||
currentType = MediaType.IMAGE
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
playerView.visibility = android.view.View.GONE
|
||||
|
|
@ -162,8 +164,27 @@ class MediaPlayerManager(
|
|||
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a YouTube embed that is being switched away from.
|
||||
*
|
||||
* Hiding the WebView does NOT stop it — visibility is not playback state, so the video kept
|
||||
* running behind the next item and its audio carried on over the top. Reported after YouTube
|
||||
* items started advancing at all (before that they never ended, so nothing ever switched away
|
||||
* from one and this could not surface): "even when the picture is there the sound from the
|
||||
* video continues playing".
|
||||
*
|
||||
* Blanking is what stop() already does, and it is safe here because playYoutube always reloads
|
||||
* the embed from scratch anyway. Guarded on the OUTGOING type so it must be called before
|
||||
* currentType is reassigned, and so it never blanks a widget that is being reused.
|
||||
*/
|
||||
private fun stopYoutubeIfPlaying() {
|
||||
if (currentType != MediaType.YOUTUBE) return
|
||||
youtubeWebView?.loadUrl("about:blank")
|
||||
}
|
||||
|
||||
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
|
||||
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
|
||||
mountGeneration++
|
||||
currentType = MediaType.YOUTUBE
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
youtubeMuted = muted || wallMute
|
||||
|
|
@ -190,13 +211,42 @@ class MediaPlayerManager(
|
|||
// would restart the video and flicker. Main thread only (WebView access).
|
||||
private fun setYoutubeMuted(muted: Boolean) {
|
||||
youtubeMuted = muted
|
||||
val func = if (muted) "mute" else "unMute"
|
||||
postYoutubeCommand(if (muted) "mute" else "unMute")
|
||||
}
|
||||
|
||||
/** Send one IFrame-API command to the embed. Main thread only (WebView access). */
|
||||
private fun postYoutubeCommand(func: String) {
|
||||
val js = "(function(){try{var f=document.querySelector('iframe');" +
|
||||
"if(f&&f.contentWindow){f.contentWindow.postMessage(" +
|
||||
"JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()"
|
||||
youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The app is going to the background. Stop making noise.
|
||||
*
|
||||
* A WebView keeps running when its Activity stops — nothing in the lifecycle pauses it — so a
|
||||
* YouTube embed carried on playing with the app closed and the audio kept coming out of the
|
||||
* panel: "I closed the app and I can still hear the sound... I force stop the app and then open
|
||||
* again." A signage player that is not on screen must be silent.
|
||||
*
|
||||
* Pause rather than blank, so returning to the foreground resumes in place instead of
|
||||
* restarting the clip. pauseTimers() is process-wide, which is fine here (one WebView) and is
|
||||
* what actually stops the embed's own scripted playback.
|
||||
*/
|
||||
fun onAppBackgrounded() {
|
||||
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("pauseVideo")
|
||||
youtubeWebView?.let { wv -> wv.post { try { wv.onPause(); wv.pauseTimers() } catch (_: Throwable) {} } }
|
||||
exoPlayer?.pause()
|
||||
}
|
||||
|
||||
/** Back in the foreground: undo onAppBackgrounded. */
|
||||
fun onAppForegrounded() {
|
||||
youtubeWebView?.let { wv -> wv.post { try { wv.resumeTimers(); wv.onResume() } catch (_: Throwable) {} } }
|
||||
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("playVideo")
|
||||
if (currentType == MediaType.VIDEO) exoPlayer?.play()
|
||||
}
|
||||
|
||||
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
|
||||
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
|
||||
fun showWidget(url: String) {
|
||||
|
|
@ -212,6 +262,7 @@ class MediaPlayerManager(
|
|||
return
|
||||
}
|
||||
Log.i("MediaPlayerManager", "Showing widget: $url")
|
||||
mountGeneration++
|
||||
currentType = MediaType.WIDGET
|
||||
currentWidgetUrl = url
|
||||
|
||||
|
|
@ -229,6 +280,8 @@ class MediaPlayerManager(
|
|||
|
||||
fun playVideoFromUrl(url: String, muted: Boolean = false) {
|
||||
Log.i("MediaPlayerManager", "Streaming video from URL: $url (muted=$muted)")
|
||||
mountGeneration++
|
||||
stopYoutubeIfPlaying()
|
||||
currentType = MediaType.VIDEO
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
|
||||
|
|
@ -244,13 +297,36 @@ class MediaPlayerManager(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped by every request to put something on screen. An async decode captures it and drops its
|
||||
* result if the value has moved on — the same drop-if-replaced token PipOverlay.loadImageInto
|
||||
* already carries.
|
||||
*
|
||||
* Without it a slow remote image (ImageLoader allows 10s connect + 30s read, against a slot
|
||||
* that is usually 10s) finished long after the playlist had advanced and mounted itself over
|
||||
* whatever was playing. If that was a video, the mount also called exoPlayer.stop(), which
|
||||
* lands in STATE_IDLE — and the advance listener only fires onVideoComplete on STATE_ENDED or a
|
||||
* playback error, so no advance was ever scheduled and the playlist stopped for good. The 60s
|
||||
* refresh could not rescue it either: the playlist signature was unchanged, so the update
|
||||
* returned early.
|
||||
*/
|
||||
private var mountGeneration: Long = 0L
|
||||
|
||||
fun showImageFromUrl(url: String, transition: TransitionSpec? = null) {
|
||||
Log.i("MediaPlayerManager", "Loading remote image: $url")
|
||||
// Capture the outgoing frame NOW, on the main thread, before the decode thread swaps it out.
|
||||
val from = if (transition != null) captureCurrentFrame() else null
|
||||
val myGeneration = ++mountGeneration
|
||||
Thread {
|
||||
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
|
||||
mainHandler.post {
|
||||
// Something else has been asked for since this decode started — including the
|
||||
// error branch, whose onImageError posts next() and would otherwise cut short
|
||||
// whatever is now playing.
|
||||
if (myGeneration != mountGeneration) {
|
||||
Log.i("MediaPlayerManager", "Dropping stale image decode: $url")
|
||||
return@post
|
||||
}
|
||||
if (bitmap == null) {
|
||||
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
|
||||
onImageError?.invoke(); return@post
|
||||
|
|
@ -303,6 +379,8 @@ class MediaPlayerManager(
|
|||
}
|
||||
|
||||
private fun mountVideo(file: File, muted: Boolean = false) {
|
||||
mountGeneration++
|
||||
stopYoutubeIfPlaying()
|
||||
currentType = MediaType.VIDEO
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ data class PlaylistItem(
|
|||
val remoteUrl: String? = null,
|
||||
val muted: Boolean = false,
|
||||
val widgetId: String? = null,
|
||||
// Changes whenever the widget is edited. Carried into the render URL so an edited widget gets
|
||||
// a URL the player has not seen, which is what defeats the deliberate same-URL WebView reuse.
|
||||
val widgetRev: Long = 0L,
|
||||
// Bumped by the server when an asset's BYTES change under a stable content id (the dashboard's
|
||||
// "replace file"). The cache is keyed on it: without one, a replaced asset would keep playing
|
||||
// the copy already on disk forever, because nothing about the id or the URL would differ.
|
||||
val contentRev: Long = 0L,
|
||||
val widgetType: String? = null,
|
||||
val schedules: List<ScheduleEval.Block> = emptyList(),
|
||||
// feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut).
|
||||
|
|
@ -39,7 +46,11 @@ class PlaylistController(
|
|||
private val onWaitingForContent: (() -> Unit)? = null,
|
||||
// Proof-of-play: emitted on each item show ("play_start") and when it's left ("play_end"),
|
||||
// so the caller can forward device:play-event to the server (populates play_logs / Reports).
|
||||
private val onPlayLog: ((event: String, item: PlaylistItem, completed: Boolean) -> Unit)? = null
|
||||
private val onPlayLog: ((event: String, item: PlaylistItem, completed: Boolean) -> Unit)? = null,
|
||||
// #234: playback position, persisted OUTSIDE this object so it survives the controller being
|
||||
// rebuilt with a new Activity. Null on both = today's behaviour (always start from the top).
|
||||
private val loadResume: (() -> Pair<Int, Long>?)? = null,
|
||||
private val saveResume: ((index: Int, atMs: Long) -> Unit)? = null
|
||||
) {
|
||||
private companion object {
|
||||
const val CONTENT_RECHECK_MS = 3000L
|
||||
|
|
@ -107,7 +118,7 @@ class PlaylistController(
|
|||
val item = currentItem ?: return
|
||||
val delay = FollowerExit.resumeDelayMs(
|
||||
isRunning = isRunning,
|
||||
isImageOrWidget = item.mimeType.startsWith("image/") || item.isWidget,
|
||||
isImageOrWidget = endsOnTimer(item),
|
||||
slotMs = slotMs(item),
|
||||
elapsedMs = System.currentTimeMillis() - itemStartedAt
|
||||
) ?: return
|
||||
|
|
@ -165,6 +176,8 @@ class PlaylistController(
|
|||
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
|
||||
muted = obj.optInt("muted", 0) == 1,
|
||||
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
|
||||
widgetRev = obj.optLong("widget_rev", 0L),
|
||||
contentRev = obj.optLong("content_rev", 0L),
|
||||
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
|
||||
schedules = parseSchedules(obj.optJSONArray("schedules")),
|
||||
transition = Transitions.parse(obj.optJSONObject("transition"))
|
||||
|
|
@ -185,7 +198,12 @@ class PlaylistController(
|
|||
// so timing edits take effect without interrupting playback or resetting the index.
|
||||
// transition included so a transition-only edit re-renders instead of being de-duped (a
|
||||
// cached-playlist device otherwise silently ignores it — the web/Tizen fingerprint bug).
|
||||
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" +
|
||||
// widgetRev for the same reason as muted and transition above: a widget's identity does not
|
||||
// change when it is EDITED, so a content edit produced a byte-identical signature, the
|
||||
// update was de-duped, and the player kept its old items — including the old rev, so the
|
||||
// render URL never changed and the WebView reuse held. The screen only caught up on an app
|
||||
// restart. Found on the emulator; the code read looked correct without it.
|
||||
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + it.widgetRev + "|" + (if (it.muted) "m" else "") + "|" +
|
||||
it.schedules.joinToString(";") { b ->
|
||||
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
|
||||
} + "|" + (it.transition?.sig() ?: "")
|
||||
|
|
@ -216,8 +234,17 @@ class PlaylistController(
|
|||
// In solo playback, don't interrupt it: keep it up, stash the new list, and rotate out on the
|
||||
// next natural advance (video end / image duration). Excludes wallFollower + group-sync, whose
|
||||
// advance is driven by their tick, not next() — deferring there would strand the swap.
|
||||
if (isRunning && !wallFollower && hasContentOnScreen && currentlyPlayingId != null &&
|
||||
newItems.none { it.contentId == currentlyPlayingId }) {
|
||||
// An EMPTY new list is never a deferral candidate. Clearing a screen's playlist is an
|
||||
// explicit "stop showing that" from an operator, not an item rotating out — deferring it
|
||||
// meant selecting "no playlist" left the old content up indefinitely, which is the opposite
|
||||
// of what was asked for and looked like the setting had done nothing.
|
||||
if (PendingSwap.shouldDefer(
|
||||
isRunning = isRunning,
|
||||
wallFollower = wallFollower,
|
||||
hasContentOnScreen = hasContentOnScreen,
|
||||
currentlyPlayingId = currentlyPlayingId,
|
||||
newContentIds = newItems.map { it.contentId },
|
||||
)) {
|
||||
var succ: String? = null
|
||||
if (items.isNotEmpty()) {
|
||||
for (k in 1..items.size) {
|
||||
|
|
@ -228,11 +255,13 @@ class PlaylistController(
|
|||
pendingItems = newItems
|
||||
pendingSuccessorId = succ
|
||||
Log.i("PlaylistController", "Current item removed but still live — deferring rotation-out (successor=$succ)")
|
||||
armPendingSwapDeadline()
|
||||
return
|
||||
}
|
||||
// A non-deferred structural update supersedes any pending swap.
|
||||
pendingItems = null
|
||||
pendingSuccessorId = null
|
||||
cancelPendingSwapDeadline()
|
||||
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
|
|
@ -289,7 +318,16 @@ class PlaylistController(
|
|||
if (firstActiveIndex() < 0) { showNothingScheduled(); return }
|
||||
// Screen-resilience: only start on an item whose content is downloaded; if the scheduled
|
||||
// content isn't ready yet, keep current/wait (never blank on a loading state).
|
||||
val idx = PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) }
|
||||
// #234: continue where we left off when this is a reload moments after playing (a new
|
||||
// Activity => a brand-new controller), not a genuine cold start. Scanning from 0 every time
|
||||
// is what pinned these playlists on their first item forever.
|
||||
val saved = try { loadResume?.invoke() } catch (_: Throwable) { null }
|
||||
val from = PlaybackResume.resumeIndex(
|
||||
saved?.first ?: -1, saved?.second ?: 0L, System.currentTimeMillis(), items.size)
|
||||
val idx = if (from > 0 && playableNow(from)) from
|
||||
else if (from > 0) PlaylistSelection.nextPlayableIndex(items.size, from - 1) { playableNow(it) }
|
||||
else PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) }
|
||||
if (from > 0) Log.i("PlaylistController", "Resuming at index $from (reload within resume window)")
|
||||
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
|
||||
}
|
||||
|
||||
|
|
@ -317,6 +355,7 @@ class PlaylistController(
|
|||
isRunning = false
|
||||
cancelAdvance()
|
||||
cancelRetry()
|
||||
cancelPendingSwapDeadline() // else a stopped controller can still fire next()
|
||||
hasContentOnScreen = false
|
||||
pendingItems = null
|
||||
pendingSuccessorId = null
|
||||
|
|
@ -330,6 +369,7 @@ class PlaylistController(
|
|||
// Swap in the stashed list now and continue at the preserved successor (or first playable).
|
||||
pendingItems?.let { p ->
|
||||
pendingItems = null
|
||||
cancelPendingSwapDeadline()
|
||||
val succ = pendingSuccessorId; pendingSuccessorId = null
|
||||
items.clear(); items.addAll(p)
|
||||
if (items.isEmpty()) { currentIndex = -1; cancelAdvance(); onPlaylistEmpty(); return }
|
||||
|
|
@ -360,11 +400,49 @@ class PlaylistController(
|
|||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Items whose turn ends on a TIMER rather than a completion callback.
|
||||
*
|
||||
* video/youtube belongs here and did not: it is played by loading an embed into a WebView, which
|
||||
* fires no completion event, so nothing ever advanced past it. playYoutube() even takes a
|
||||
* durationSec and never reads it. A playlist containing a YouTube item simply stopped there.
|
||||
*
|
||||
* That also stranded #157's deferred swap, which waits for "the next natural advance": assigning
|
||||
* a different playlist while a YouTube item was on screen deferred forever, so the change looked
|
||||
* like it had been ignored. Reported as "I assigned Playlist 2 and it kept showing the video".
|
||||
*
|
||||
* Local and remote non-YouTube video stay off this list — ExoPlayer reports STATE_ENDED and
|
||||
* onVideoComplete drives those, and a timer would cut a clip short.
|
||||
*/
|
||||
private var pendingSwapRunnable: Runnable? = null
|
||||
|
||||
/** Apply a deferred swap even if no advance arrives — see PENDING_SWAP_DEADLINE_MS. */
|
||||
private fun armPendingSwapDeadline() {
|
||||
cancelPendingSwapDeadline()
|
||||
pendingSwapRunnable = Runnable {
|
||||
if (pendingItems != null) {
|
||||
Log.w("PlaylistController", "Deferred playlist swap never got an advance — applying it now")
|
||||
next()
|
||||
}
|
||||
}
|
||||
handler.postDelayed(pendingSwapRunnable!!, PendingSwap.DEADLINE_MS)
|
||||
}
|
||||
|
||||
private fun cancelPendingSwapDeadline() {
|
||||
pendingSwapRunnable?.let { handler.removeCallbacks(it) }
|
||||
pendingSwapRunnable = null
|
||||
}
|
||||
|
||||
private fun endsOnTimer(item: PlaylistItem): Boolean =
|
||||
ItemTiming.endsOnTimer(item.mimeType, item.isWidget)
|
||||
|
||||
private fun playCurrentItem() {
|
||||
cancelAdvance()
|
||||
cancelRetry()
|
||||
val item = currentItem ?: return
|
||||
itemStartedAt = System.currentTimeMillis()
|
||||
// #234: remember where we are so a controller rebuilt seconds from now can carry on.
|
||||
try { saveResume?.invoke(currentIndex, itemStartedAt) } catch (_: Throwable) {}
|
||||
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
|
||||
onItemChanged(item)
|
||||
hasContentOnScreen = true // a valid item is now rendered — protect it from being blanked
|
||||
|
|
@ -380,7 +458,7 @@ class PlaylistController(
|
|||
// For images and widgets, auto-advance after duration. For videos, wait
|
||||
// for the completion callback. Wall followers never auto-advance — the
|
||||
// leader's wall:sync index drives every switch.
|
||||
if (!wallFollower && (item.mimeType.startsWith("image/") || item.isWidget)) {
|
||||
if (!wallFollower && endsOnTimer(item)) {
|
||||
// slotMs() floors a zero/negative duration to 10s (the max(1, duration||10)
|
||||
// contract shared with the web/Tizen players). A raw durationSec*1000 here let a
|
||||
// solo fullscreen widget with duration_sec=0 schedule a 0ms advance -> self-loop.
|
||||
|
|
@ -466,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 }
|
||||
|
||||
/**
|
||||
|
|
@ -44,3 +77,94 @@ object PlaylistSelection {
|
|||
fun whenNonePlayable(hasContentOnScreen: Boolean): NonePlayable =
|
||||
if (hasContentOnScreen) NonePlayable.KEEP_CURRENT else NonePlayable.SHOW_WAITING
|
||||
}
|
||||
|
||||
/**
|
||||
* #234 — where playback should RESUME when a playlist is (re)loaded.
|
||||
*
|
||||
* PlaylistController is created fresh with every MainActivity instance, so a recreate always handed
|
||||
* it an empty list and then a full one, which reads as "0 -> N items" and starts from the top. On a
|
||||
* panel that re-registers and relaunches itself at each item boundary, item 2 therefore never
|
||||
* survived more than a fraction of a second: the reporter of #234 had "never seen the photo, just
|
||||
* the video", and prod play_logs showed the second item logging 0-1s durations while the first
|
||||
* accumulated all the playtime.
|
||||
*
|
||||
* Starting from the top is only correct for a genuinely COLD start. If we were playing moments ago,
|
||||
* the right thing is to carry on. Kept pure so the window arithmetic is testable without a device.
|
||||
*/
|
||||
object PlaybackResume {
|
||||
/** How recently we must have been playing for a reload to count as a continuation. */
|
||||
const val RESUME_WINDOW_MS = 90_000L
|
||||
|
||||
/**
|
||||
* Index to begin scanning from. [savedIndex] < 0, an empty/short playlist, a stale save, or a
|
||||
* clock that jumped backwards all fall back to 0 — i.e. to today's behaviour, so a real cold
|
||||
* start is unaffected.
|
||||
*/
|
||||
fun resumeIndex(savedIndex: Int, savedAtMs: Long, nowMs: Long, itemCount: Int): Int {
|
||||
if (itemCount <= 0) return 0
|
||||
if (savedIndex < 0 || savedIndex >= itemCount) return 0
|
||||
if (savedAtMs <= 0L) return 0
|
||||
val age = nowMs - savedAtMs
|
||||
if (age < 0L || age > RESUME_WINDOW_MS) return 0
|
||||
return savedIndex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #157's deferral: when a playlist update drops the item that is CURRENTLY on screen, we let that
|
||||
* item finish its turn instead of yanking it, and apply the new list at the next natural advance.
|
||||
*
|
||||
* The rule needs two guards it did not have, both found from a customer report where a playlist
|
||||
* change appeared to be ignored entirely:
|
||||
*
|
||||
* 1. An EMPTY new list is not a rotation. Clearing a screen's playlist is an operator saying "stop
|
||||
* showing that", so it must take effect now. Deferring it left the old content up forever.
|
||||
* 2. Deferring assumes an advance is coming. A YouTube item never advanced (see endsOnTimer), so
|
||||
* the pending swap was stranded permanently — the caller must pair this with a deadline.
|
||||
*
|
||||
* Pure so the rule can be checked without a device or a WebView.
|
||||
*/
|
||||
object PendingSwap {
|
||||
/**
|
||||
* How long a deferred swap may wait for "the next natural advance" before it is applied anyway.
|
||||
* The deferral assumes an advance is coming; YouTube proved it might not be, and any future item
|
||||
* type that ends on a callback could do the same. Must comfortably clear an ordinary dwell so it
|
||||
* never pre-empts a normal rotation, while still being short enough that an operator watching
|
||||
* the screen sees their change land.
|
||||
*/
|
||||
const val DEADLINE_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Whether a playlist update should wait for the current item to finish.
|
||||
* False means apply it immediately.
|
||||
*/
|
||||
fun shouldDefer(
|
||||
isRunning: Boolean,
|
||||
wallFollower: Boolean,
|
||||
hasContentOnScreen: Boolean,
|
||||
currentlyPlayingId: String?,
|
||||
newContentIds: List<String>,
|
||||
): Boolean {
|
||||
if (!isRunning || wallFollower || !hasContentOnScreen) return false
|
||||
if (currentlyPlayingId == null) return false
|
||||
if (newContentIds.isEmpty()) return false // guard 1: an explicit stop
|
||||
return !newContentIds.contains(currentlyPlayingId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which items end on a TIMER versus a completion callback.
|
||||
*
|
||||
* video/youtube was in neither camp and so ended on nothing at all: it is played by loading an embed
|
||||
* into a WebView, which reports no completion, and no advance was ever armed for it. The item's
|
||||
* configured duration was passed to the player and dropped on the floor. A playlist containing a
|
||||
* YouTube item simply stopped there for good, and any pending playlist change stopped with it.
|
||||
*
|
||||
* Local and remote video deliberately stay OFF the timer path — the player reports STATE_ENDED for
|
||||
* those and a timer would cut a clip short at its configured duration.
|
||||
*/
|
||||
object ItemTiming {
|
||||
fun endsOnTimer(mimeType: String, isWidget: Boolean): Boolean =
|
||||
mimeType.startsWith("image/") || isWidget || mimeType == "video/youtube"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,12 @@ object ScheduleEval {
|
|||
val nowMin = zdt.hour * 60 + zdt.minute
|
||||
val date = zdt.toLocalDate()
|
||||
blocks.any { blockMatches(it, dow, nowMin, date) }
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
// Throwable, not Exception. A missing java.time on an old API level surfaces as
|
||||
// NoClassDefFoundError — an Error — which sailed straight through a catch(Exception)
|
||||
// and turned this "fail open, a blank screen is worse than an over-running promo"
|
||||
// contract into its exact opposite: nothing played at all. Desugaring (see
|
||||
// build.gradle.kts) is the real fix; this makes the guard mean what it says.
|
||||
true // fail open
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ class ZoneManager(
|
|||
var currentLayoutId: String? = null
|
||||
private set
|
||||
var lastAssignmentSig: String? = null
|
||||
// Geometry of the zones currently built. Editing a layout in place keeps its id, so the id
|
||||
// alone cannot tell "same layout, same zones" from "same layout, zones changed".
|
||||
var lastZoneSig: String? = null
|
||||
|
||||
// #74/#75: device-effective IANA timezone for per-item schedule evaluation.
|
||||
@Volatile private var effectiveTimezone: String? = null
|
||||
|
|
@ -207,8 +210,12 @@ class ZoneManager(
|
|||
widgetType != null -> {
|
||||
val widgetId = a.optString("widget_id", "")
|
||||
val webView = createWebView()
|
||||
// rev, exactly as the fullscreen path does: a widget's id does not change when it
|
||||
// is edited, so without it a zone kept rendering the old content indefinitely.
|
||||
val wRev = a.optLong("widget_rev", 0L)
|
||||
val wUrl = "$renderServerUrl/api/widgets/$widgetId/render" +
|
||||
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "")
|
||||
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "?d=") +
|
||||
"&rev=" + wRev
|
||||
webView.loadUrl(wUrl)
|
||||
webView.layoutParams = params
|
||||
container.addView(webView); zoneViews[zone.id] = webView
|
||||
|
|
@ -245,6 +252,14 @@ class ZoneManager(
|
|||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED) handler.post { advance() }
|
||||
}
|
||||
// Same reason MediaPlayerManager treats a playback error as a completion
|
||||
// ("Root-2: a corrupt/undecodable video used to freeze the playlist
|
||||
// forever"): an error lands in STATE_IDLE, never STATE_ENDED, so without
|
||||
// this the zone stops rotating and goes black until the layout changes or
|
||||
// the app restarts — while every other zone keeps going.
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
handler.post { advance() }
|
||||
}
|
||||
})
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package com.remotedisplay.player.service
|
||||
|
||||
/**
|
||||
* #234 follow-up: how often a device may ask the server to re-send its playlist.
|
||||
*
|
||||
* "Refresh" is not cheap. requestPlaylistRefresh() emits a full `device:register`, and the server's
|
||||
* register handler runs 7+ statements plus the whole fingerprint/identity path and rebuilds the
|
||||
* playlist payload. PlaylistController.next() was calling it on EVERY item advance, so a panel on a
|
||||
* 10-second image re-registered six times a minute, forever — and each reply pushed a full playlist
|
||||
* back down, which is what kept feeding the restart loop behind #234.
|
||||
*
|
||||
* It was also redundant: the heartbeat already refreshes every 4th beat (60s), so the periodic pull
|
||||
* this was duplicating exists either way. Throttling at the single chokepoint keeps every caller's
|
||||
* intent — recovery paths still refresh, they just cannot stack up — without having to rank them.
|
||||
*
|
||||
* Pure so the interval arithmetic is testable without a device or a socket.
|
||||
*/
|
||||
object RefreshThrottle {
|
||||
/** Just under the heartbeat's own 60s pull, so the two interleave instead of cancelling out. */
|
||||
const val MIN_INTERVAL_MS = 55_000L
|
||||
|
||||
fun shouldRefresh(lastAtMs: Long, nowMs: Long): Boolean {
|
||||
if (lastAtMs <= 0L) return true // never refreshed — always allow the first
|
||||
val since = nowMs - lastAtMs
|
||||
if (since < 0L) return true // clock corrected backwards; never wedge on it
|
||||
return since >= MIN_INTERVAL_MS
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ class UpdateChecker(private val context: Context) {
|
|||
private val CHECK_INTERVAL = 30 * 60 * 1000L
|
||||
|
||||
private var installReceiverRegistered = false
|
||||
// Held so shutdown() can unregister it; without a handle the receiver outlives the Activity.
|
||||
private var installReceiver: BroadcastReceiver? = null
|
||||
|
||||
// #139: report OTA status to the dashboard (device:log, tag "ota"). Wired by MainActivity
|
||||
// to WebSocketService.sendLog; null until then. Read lazily so binding order doesn't matter.
|
||||
|
|
@ -45,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) {}
|
||||
|
|
@ -92,6 +106,7 @@ class UpdateChecker(private val context: Context) {
|
|||
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
|
||||
}
|
||||
installReceiverRegistered = true
|
||||
installReceiver = receiver
|
||||
}
|
||||
|
||||
fun startPeriodicCheck() {
|
||||
|
|
@ -113,6 +128,25 @@ class UpdateChecker(private val context: Context) {
|
|||
checkTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Full teardown for an Activity that is going away.
|
||||
*
|
||||
* stopPeriodicCheck alone leaves the install receiver registered against a dead Context, and
|
||||
* installReceiverRegistered is per-instance — so each Activity recreate produced another
|
||||
* checker polling /api/update/check and another receiver for INSTALL_COMPLETE. N of those means
|
||||
* one STATUS_PENDING_USER_ACTION fires N confirm dialogs over customer content, and concurrent
|
||||
* checkers race in tryPackageInstaller, which begins by abandoning ALL of this app's installer
|
||||
* sessions — so one can abandon another's staged session mid-flight and the update never lands.
|
||||
*/
|
||||
fun shutdown() {
|
||||
stopPeriodicCheck()
|
||||
if (installReceiverRegistered) {
|
||||
installReceiver?.let { r -> try { context.unregisterReceiver(r) } catch (_: Throwable) { /* already gone */ } }
|
||||
installReceiver = null
|
||||
installReceiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [forced] = an operator pressed "force update" on this specific device, rather than the
|
||||
* 30-minute timer firing. A forced run differs in three ways, all because a human aimed it at
|
||||
|
|
@ -250,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
|
||||
}
|
||||
|
||||
|
|
@ -289,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
|
||||
|
|
@ -310,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
|
||||
}
|
||||
|
|
@ -329,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
|
||||
|
|
@ -342,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
|
||||
}
|
||||
|
|
@ -356,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 }
|
||||
|
|
@ -460,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
|
||||
|
|
@ -476,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
|
||||
}
|
||||
|
|
@ -489,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
|
||||
}
|
||||
|
|
@ -511,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
|
||||
|
|
|
|||
|
|
@ -432,6 +432,18 @@ class WebSocketService : Service() {
|
|||
handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } }
|
||||
}
|
||||
|
||||
// A PIN set or rotated from the dashboard takes effect NOW, not at the next pairing.
|
||||
// Without this an operator who rotated a leaked PIN would believe they had revoked
|
||||
// access while the old one still opened the menu — worse than not offering it.
|
||||
safeOn("device:settings-pin") { args ->
|
||||
val data = args.getOrNull(0) as? org.json.JSONObject ?: return@safeOn
|
||||
val pin = data.optString("settings_pin", "")
|
||||
if (pin.isNotEmpty()) {
|
||||
config.settingsPin = pin
|
||||
Log.i("WebSocketService", "Settings PIN updated from dashboard") // never log the PIN
|
||||
}
|
||||
}
|
||||
|
||||
safeOn("device:playlist-update") { args ->
|
||||
val data = args.firstOrNull() as? JSONObject ?: run {
|
||||
Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}")
|
||||
|
|
@ -584,8 +596,18 @@ class WebSocketService : Service() {
|
|||
}
|
||||
} catch (e: Throwable) { Log.e("WebSocketService", "screen_off: ${e.message}") }
|
||||
}
|
||||
// No privileged wake on a non-rooted panel (keyevent 224 was denied); retired.
|
||||
"screen_on" -> Log.w("WebSocketService", "screen_on: no privileged wake path — no-op")
|
||||
// Was a no-op because `input keyevent 224` is denied to an app UID — but a
|
||||
// wake LOCK is a different mechanism needing only WAKE_LOCK, which we hold.
|
||||
// Handled here as well as in MainActivity so a panel whose Activity is not
|
||||
// foregrounded can still be woken; the service is the only thing guaranteed
|
||||
// to be alive, and "screen won't come back on" means a site visit.
|
||||
"screen_on" -> {
|
||||
val woke = com.remotedisplay.player.system.SystemControl(applicationContext).wakeScreen()
|
||||
Log.i("WebSocketService", "screen_on: wake=$woke")
|
||||
// Bring the player back in front of the keyguard too. Same fail-loud
|
||||
// reasoning as Relauncher: waking to a lock screen is only half a fix.
|
||||
handler.post { try { onCommand?.invoke("screen_on", payload) } catch (_: Throwable) {} }
|
||||
}
|
||||
"set_debug" -> {
|
||||
val on = payload?.optBoolean("enabled", false) ?: false
|
||||
// Point the sink at this socket, then flip the flag. When on,
|
||||
|
|
@ -645,6 +667,12 @@ class WebSocketService : Service() {
|
|||
put("client_version", deviceInfo.getAppVersion())
|
||||
put("platform", "Android " + android.os.Build.VERSION.RELEASE)
|
||||
put("contract_version", "v4")
|
||||
// What this panel can actually do, so the dashboard stops offering controls that
|
||||
// cannot work on it. Recomputed on EVERY register rather than cached: accessibility
|
||||
// gets switched on months after install, device owner arrives via provisioning, and
|
||||
// WRITE_SETTINGS can be revoked — a value captured once would be wrong on the same
|
||||
// hardware from one boot to the next.
|
||||
put("capabilities", com.remotedisplay.player.telemetry.PlayerCapabilities.declare(this@WebSocketService))
|
||||
} catch (e: Throwable) { Log.w("WebSocketService", "identity: ${e.message}") }
|
||||
}
|
||||
|
||||
|
|
@ -729,6 +757,21 @@ class WebSocketService : Service() {
|
|||
|
||||
/** True from the first server rejection until the device is (re)paired — UI stays on re-pair. */
|
||||
fun isAwaitingRepair(): Boolean = awaitingRepair
|
||||
|
||||
/**
|
||||
* Why the server last refused us, verbatim from device:auth-error (e.g. "Device blocked").
|
||||
* The server always says why; the player used to throw it away and fall back to a generic
|
||||
* connection failure, so an operator block read as "couldn't reach the server, check the url"
|
||||
* and sent people off debugging their network. #234.
|
||||
*/
|
||||
@Volatile var lastRejectionReason: String? = null
|
||||
/**
|
||||
* True when the last rejection came with a settle window — the server is asking us to wait and
|
||||
* try again, not telling us we are gone. This service already holds, retries once and recovers
|
||||
* on its own, so a listener must not tear the player down over it.
|
||||
*/
|
||||
@Volatile var lastRejectionTransient: Boolean = false
|
||||
private set
|
||||
/** Milliseconds left in the reclaim-settle hold (0 once elapsed) — drives the UI countdown. */
|
||||
fun repairHoldRemainingMs(): Long = maxOf(0L, repairHoldUntilMs - SystemClock.elapsedRealtime())
|
||||
/** True only when the shown pairing code is server-accepted (pairable) — not a rejected/stale one. */
|
||||
|
|
@ -748,7 +791,9 @@ class WebSocketService : Service() {
|
|||
* scheduled retry, so the screen is stable — no register/reject/register churn.
|
||||
*/
|
||||
private fun handleServerRejection(reason: String) {
|
||||
lastRejectionReason = reason
|
||||
val settleSec = parseSettleSeconds(reason)
|
||||
lastRejectionTransient = settleSec > 0
|
||||
Log.w("WebSocketService", "Server rejected device ($reason) — settle=${settleSec}s")
|
||||
pairingCodeLive = false // this registration was rejected — the local code is NOT pairable
|
||||
config.clearDeviceCredentials()
|
||||
|
|
@ -778,6 +823,7 @@ class WebSocketService : Service() {
|
|||
|
||||
/** Re-pair complete (device:paired, or a normal authenticated reconnect) — clear all repair state. */
|
||||
private fun resetRepairBackoff() {
|
||||
lastRejectionReason = null
|
||||
repairRetryPending = false
|
||||
repairBackoffMs = 0L
|
||||
awaitingRepair = false
|
||||
|
|
@ -845,8 +891,17 @@ class WebSocketService : Service() {
|
|||
connect()
|
||||
}
|
||||
|
||||
@Volatile private var lastRefreshAt = 0L
|
||||
|
||||
fun requestPlaylistRefresh() {
|
||||
if (socket?.connected() != true || config.deviceId.isEmpty()) return
|
||||
// #234 follow-up: this emits a FULL device:register (7+ server statements + the identity
|
||||
// path + a playlist rebuild), and PlaylistController.next() calls it on every item advance.
|
||||
// A 10-second image therefore re-registered six times a minute. The heartbeat already pulls
|
||||
// a fresh playlist every 60s, so the per-item call bought nothing and cost a great deal.
|
||||
val now = System.currentTimeMillis()
|
||||
if (!RefreshThrottle.shouldRefresh(lastRefreshAt, now)) return
|
||||
lastRefreshAt = now
|
||||
Log.i("WebSocketService", "Requesting playlist refresh")
|
||||
try {
|
||||
val data = org.json.JSONObject().apply {
|
||||
|
|
|
|||
|
|
@ -94,5 +94,42 @@ class SystemControl(private val context: Context) {
|
|||
} catch (e: Throwable) { Log.w(TAG, "setScreenOffTimeout: ${e.message}"); false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake the panel. The other half of screen_off, which had none.
|
||||
*
|
||||
* screen_off has always worked — device owner / admin FORCE_LOCK, or the accessibility lock —
|
||||
* but screen_on was a logged no-op on the belief that a non-rooted panel has no privileged
|
||||
* wake path. The retired attempt was `input keyevent 224`, which exec denies to an app UID; a
|
||||
* wake LOCK is a different mechanism and needs only WAKE_LOCK, a normal permission we already
|
||||
* hold. So the conclusion ("no wake path") was drawn from one failed approach.
|
||||
*
|
||||
* That asymmetry is worse than it sounds on a signage fleet: an operator turns a panel off for
|
||||
* the night and cannot turn it back on remotely, so someone drives to the site. Losing the
|
||||
* screen is the expensive direction to fail in.
|
||||
*
|
||||
* ACQUIRE_CAUSES_WAKEUP + SCREEN_BRIGHT is deprecated (API 17) and still the only app-level
|
||||
* wake there is; on a device that ignores it we return false rather than pretending. Held
|
||||
* briefly and released — an indefinite wake lock would pin the panel on and defeat every
|
||||
* screen-off command that follows.
|
||||
*/
|
||||
fun wakeScreen(holdMs: Long = 3000L): Boolean = try {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
|
||||
@Suppress("DEPRECATION")
|
||||
val lock = pm.newWakeLock(
|
||||
android.os.PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||
android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||
android.os.PowerManager.ON_AFTER_RELEASE,
|
||||
"screentinker:wake"
|
||||
)
|
||||
// Time out on its own as well as being released below: if the release is ever missed, a
|
||||
// self-expiring lock still lets the panel sleep instead of burning in.
|
||||
lock.acquire(holdMs)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
try { if (lock.isHeld) lock.release() } catch (_: Throwable) { }
|
||||
}, holdMs)
|
||||
Log.i(TAG, "wakeScreen: wake lock acquired for ${holdMs}ms")
|
||||
true
|
||||
} catch (e: Throwable) { Log.w(TAG, "wakeScreen: ${e.message}"); false }
|
||||
|
||||
companion object { private const val TAG = "SystemControl" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,15 @@ class DeviceInfo(private val context: Context) {
|
|||
put("ram_total_mb", getRamTotalMB())
|
||||
put("cpu_usage", getCpuUsage())
|
||||
put("wifi_ssid", getWifiSSID())
|
||||
// The screen's OWN address on the network. The server separately records the PUBLIC
|
||||
// address it sees the connection from; showing only that had customers reading their
|
||||
// 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)
|
||||
|
|
@ -102,8 +111,12 @@ class DeviceInfo(private val context: Context) {
|
|||
Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_OFF_TIMEOUT, 0)
|
||||
} catch (_: Throwable) { 0 }
|
||||
|
||||
/** #160: is OUR accessibility service currently enabled (drives remote-control availability). */
|
||||
private fun isAccessibilityEnabled(): Boolean = try {
|
||||
/**
|
||||
* #160: is OUR accessibility service currently enabled (drives remote-control availability).
|
||||
* Internal rather than private because the capability declaration asks the same question, and
|
||||
* a second copy of this check would drift from the telemetry the dashboard shows beside it.
|
||||
*/
|
||||
internal fun isAccessibilityEnabled(): Boolean = try {
|
||||
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
|
||||
as android.view.accessibility.AccessibilityManager
|
||||
val mine = android.content.ComponentName(context,
|
||||
|
|
@ -168,17 +181,85 @@ class DeviceInfo(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connected Wi-Fi network name, or a value saying WHY we do not have it.
|
||||
*
|
||||
* Android 8.1+ hides the SSID from apps without location permission, returning the literal
|
||||
* "<unknown ssid>". We report "Unknown" for that, which reads as a fault in the player — a
|
||||
* customer reasonably assumed it needed device-owner access. It needs LOCATION, which this app
|
||||
* deliberately does not require: a signage player asking for location to display a network name
|
||||
* is a poor trade. It can be granted from the setup screen if someone wants the field filled in.
|
||||
*
|
||||
* So: "permission" when we are not allowed to know, null when there is genuinely no Wi-Fi (an
|
||||
* Ethernet panel), and the name otherwise. The dashboard can then say something true.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getWifiSSID(): String {
|
||||
private fun getWifiSSID(): String? {
|
||||
return try {
|
||||
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
val info = wm.connectionInfo
|
||||
info.ssid?.replace("\"", "") ?: "Unknown"
|
||||
val raw = wm.connectionInfo?.ssid?.replace("\"", "")
|
||||
when {
|
||||
raw.isNullOrEmpty() -> null
|
||||
// What the platform hands back when location is missing or switched off.
|
||||
raw.equals("<unknown ssid>", ignoreCase = true) || raw == "0x" -> "permission"
|
||||
else -> raw
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Unknown"
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** First non-loopback IPv4 on any up interface (Wi-Fi or Ethernet). No permission needed. */
|
||||
private fun getLocalIp(): 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.isLoopbackAddress && addr is java.net.Inet4Address) { found = addr.hostAddress; break }
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
package com.remotedisplay.player.telemetry
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.remotedisplay.player.admin.STPolicy
|
||||
import org.json.JSONArray
|
||||
|
||||
/**
|
||||
* What THIS panel can actually do, right now.
|
||||
*
|
||||
* The dashboard used to offer every control to every display, so buttons that could never work on
|
||||
* a given panel sat there and did nothing when pressed. The server-side vocabulary lives in
|
||||
* `server/lib/player-capabilities.js`; these strings must match it exactly, because an unknown
|
||||
* name is dropped on arrival and a renamed one silently removes a control from every panel still
|
||||
* reporting the old spelling.
|
||||
*
|
||||
* ⚠️ Computed at REGISTRATION, not build time. Almost everything interesting here is runtime state
|
||||
* an APK cannot know about itself: accessibility gets switched on months after install, device
|
||||
* owner is granted by a provisioning flow, WRITE_SETTINGS is a per-device grant an operator may
|
||||
* revoke. A static list would be wrong on the same hardware from one boot to the next.
|
||||
*
|
||||
* The rule when uncertain is to UNDER-claim. A missing control is a support question; a control
|
||||
* that appears to work and does nothing is a bug report, and on a panel nobody can reach it is an
|
||||
* expensive one.
|
||||
*/
|
||||
object PlayerCapabilities {
|
||||
|
||||
/**
|
||||
* The capability set for this device, as a JSON array ready to attach to the register payload.
|
||||
* Never throws: a failure here must not cost the panel its registration, so the worst case is
|
||||
* an empty declaration, which the server reads as "declares nothing meaningful".
|
||||
*/
|
||||
fun declare(context: Context): JSONArray {
|
||||
val caps = mutableListOf<String>()
|
||||
try {
|
||||
val policy = STPolicy(context)
|
||||
val isOwner = policy.isDeviceOwner()
|
||||
val canInstall = policy.canInstallSilently()
|
||||
val canWriteSettings = try { Settings.System.canWrite(context) } catch (_: Throwable) { false }
|
||||
val accessibility = DeviceInfo(context).isAccessibilityEnabled()
|
||||
|
||||
// ---- always true on the Android player -------------------------------------------------
|
||||
// Every content type the playlist engine renders, plus the layout features built on it.
|
||||
caps += listOf(
|
||||
"playback.video", "playback.image", "playback.widget", "playback.youtube",
|
||||
"playback.zones", "playback.transitions", "playback.pip",
|
||||
// Mute reaches the YouTube embed through the IFrame API bridge, not just <video>,
|
||||
// so this is a real claim rather than the half-truth the browser players carried.
|
||||
"audio.mute", "audio.volume",
|
||||
// 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.
|
||||
"system.restart_player", "system.self_update",
|
||||
// Clock-derived group sync is platform-independent.
|
||||
"sync.clock",
|
||||
// Content is cached to local storage and survives a server outage.
|
||||
"offline.cache",
|
||||
// App-UID `sh -c`. Deliberately NOT gated on device owner: it runs at any tier and
|
||||
// is the diagnostic path the dashboard already relies on. Gated server-side instead.
|
||||
"system.shell"
|
||||
)
|
||||
|
||||
// ---- conditional on runtime state -------------------------------------------------------
|
||||
|
||||
// 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
|
||||
// path — offering a control that sleeps a panel it cannot wake would be the worst
|
||||
// possible version of this feature.
|
||||
if (isOwner || policy.isAdminActive() || accessibility) caps += "display.power"
|
||||
|
||||
// Owner-only reboot. Off-owner it degrades to an accessibility power DIALOG, which needs
|
||||
// someone standing at the screen — not a remote capability.
|
||||
if (isOwner) caps += "system.reboot"
|
||||
|
||||
// Silent lock-task. Off-owner startLockTask() gives screen pinning, which prompts for
|
||||
// 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"
|
||||
|
||||
// Silent install: device owner, or a foreign DPC that delegated the install scope.
|
||||
if (canInstall) caps += "system.install_apk"
|
||||
|
||||
// System-wide brightness and screen-off timeout: WRITE_SETTINGS, or an owner writing the
|
||||
// setting directly. Per-window dimming works at any tier but is not what the operator
|
||||
// means by "brightness", so it does not earn the claim on its own.
|
||||
if (canWriteSettings || isOwner) caps += listOf("system.brightness", "system.screen_timeout")
|
||||
|
||||
Log.i(TAG, "Capabilities: ${caps.size} declared (owner=$isOwner install=$canInstall " +
|
||||
"writeSettings=$canWriteSettings a11y=$accessibility)")
|
||||
} catch (e: Throwable) {
|
||||
// An empty array is honest here. Falling back to "everything" would put us straight back
|
||||
// to buttons that do nothing, which is the failure this whole model exists to remove.
|
||||
Log.w(TAG, "Capability detection failed: ${e.message}")
|
||||
}
|
||||
return JSONArray(caps)
|
||||
}
|
||||
|
||||
private const val TAG = "PlayerCapabilities"
|
||||
}
|
||||
|
||||
/*
|
||||
* Deliberately NOT declared on Android, so the dashboard stops offering them:
|
||||
*
|
||||
* display.resolution Setting the output mode needs system/root. The panel runs at whatever the
|
||||
* display negotiated and an app cannot change it.
|
||||
* sync.native Frame-accurate hardware sync is a BrightSign SyncManager feature. Android's
|
||||
* clock-derived group sync is declared instead, which is what it actually has.
|
||||
*/
|
||||
|
|
@ -446,6 +446,66 @@
|
|||
android:paddingBottom="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- OPTIONAL: Wi-Fi network name. Android 8.1+ will not tell an app the connected SSID
|
||||
without location permission, so the device page shows "unavailable" without this.
|
||||
Nothing else in the player uses location, and nothing else changes if it is refused —
|
||||
this row exists so it is a choice rather than a silent blank field. -->
|
||||
<LinearLayout
|
||||
android:id="@+id/locationRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="5dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Wi-Fi Name (optional)"
|
||||
android:textColor="#F1F5F9"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Android needs location permission to reveal the network name. Nothing else uses it."
|
||||
android:textColor="#64748B"
|
||||
android:textSize="8sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/locationStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="OFF"
|
||||
android:textColor="#EF4444"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/enableLocationBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="0dp"
|
||||
android:minWidth="0dp"
|
||||
android:text="Enable"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:background="@drawable/button_primary"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Default launcher / HOME. A signage kiosk MUST be the device's default launcher, or Android
|
||||
keeps returning to the stock launcher and the player is torn down + recreated on a loop
|
||||
(never renders). Not applicable where you can't set a launcher (e.g. some Android TV). -->
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@
|
|||
<string name="accessibility_description">RemoteDisplay nutzt die Bedienungshilfen, um Fernsteuerung der Stromzufuhr und Systemnavigation zu ermöglichen.</string>
|
||||
<string name="nothing_scheduled">Derzeit ist nichts geplant</string>
|
||||
<string name="waiting_for_content">Warte auf Inhalte…</string>
|
||||
<string name="device_blocked_status">Dieser Bildschirm wurde im Dashboard gesperrt</string>
|
||||
<string name="device_unpaired_status">Dieser Bildschirm wurde entkoppelt — warte auf erneute Kopplung</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@
|
|||
<string name="accessibility_description">RemoteDisplay usa accesibilidad para habilitar el control remoto de encendido y la navegación del sistema.</string>
|
||||
<string name="nothing_scheduled">No hay nada programado en este momento</string>
|
||||
<string name="waiting_for_content">Esperando contenido…</string>
|
||||
<string name="device_blocked_status">Esta pantalla ha sido bloqueada en el panel</string>
|
||||
<string name="device_unpaired_status">Esta pantalla se desvinculó — esperando volver a vincularse</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@
|
|||
<string name="accessibility_description">RemoteDisplay utilise l\'accessibilité pour activer les contrôles d\'alimentation à distance et la navigation système.</string>
|
||||
<string name="nothing_scheduled">Rien de programmé pour le moment</string>
|
||||
<string name="waiting_for_content">En attente de contenu…</string>
|
||||
<string name="device_blocked_status">Cet écran a été bloqué dans le tableau de bord</string>
|
||||
<string name="device_unpaired_status">Cet écran a été dissocié — en attente d\'un nouvel appairage</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@
|
|||
<resources>
|
||||
<string name="app_name">RemoteDisplay</string>
|
||||
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
|
||||
<string name="device_blocked_status">यह स्क्रीन डैशबोर्ड में अवरोधित कर दी गई है</string>
|
||||
<string name="device_unpaired_status">यह स्क्रीन अनयुग्मित हो गई — फिर से युग्मित होने की प्रतीक्षा</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@
|
|||
<string name="accessibility_description">RemoteDisplay usa acessibilidade para habilitar controles remotos de energia e navegação do sistema.</string>
|
||||
<string name="nothing_scheduled">Nada programado no momento</string>
|
||||
<string name="waiting_for_content">Aguardando conteúdo…</string>
|
||||
<string name="device_blocked_status">Este ecrã foi bloqueado no painel</string>
|
||||
<string name="device_unpaired_status">Este ecrã foi desemparelhado — a aguardar novo emparelhamento</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
<string name="hw_enroll_constraints">Or scan the provisioning QR from the dashboard after a factory reset (tap the setup-wizard Welcome screen 6×). Device owner is optional — the app works fully without it.</string>
|
||||
<string name="hw_recheck">Re-check</string>
|
||||
<string name="settings_info_device">Device</string>
|
||||
<string name="settings_exit_kiosk">Exit kiosk mode</string>
|
||||
<string name="settings_exit_kiosk_done">Kiosk mode off. It stays off until re-enabled from the dashboard.</string>
|
||||
<string name="settings_exit">Exit app</string>
|
||||
<string name="settings_save">Save</string>
|
||||
<string name="settings_exit_title">Exit ScreenTinker?</string>
|
||||
|
|
@ -39,4 +41,8 @@
|
|||
<string name="settings_perm_notifications">Notifications</string>
|
||||
<string name="settings_perm_hint">Tap \"Open\" to manage permissions in system settings</string>
|
||||
<string name="settings_perm_open">Open settings</string>
|
||||
<string name="settings_perm_done">Done</string>
|
||||
<string name="settings_perm_manage">Manage permissions</string>
|
||||
<string name="device_blocked_status">This screen has been blocked in the dashboard</string>
|
||||
<string name="device_unpaired_status">This screen was unpaired — waiting to be re-paired</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package com.remotedisplay.player.data
|
|||
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
|
|
@ -14,11 +16,13 @@ import java.nio.file.Files
|
|||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Root-2 REPRODUCE-THEN-PROVE for the "stuck downloading / frozen" caching bug. Each test drives
|
||||
* the REAL ContentCache.downloadContent against a local HTTP server that reproduces a specific
|
||||
* failure mode on a HEALTHY socket (the socket is fine — the DOWNLOAD misbehaves), and proves the
|
||||
* fix: a stalled/trickling download aborts instead of hanging forever, and a truncated body is
|
||||
* never promoted to the cache (so it can't be played as if whole and wedge the playlist).
|
||||
* Root-2 REPRODUCE-THEN-PROVE for the "stuck downloading / frozen" caching bug, extended with the
|
||||
* RESUME case that came out of a customer on an unstable one-bar 5G link: their screens showed the
|
||||
* waiting state instead of playing, and the reason was not the playback path at all — the asset
|
||||
* could never finish downloading, so there was never anything cached to play.
|
||||
*
|
||||
* Each test drives the REAL ContentCache against a local HTTP server that reproduces a specific
|
||||
* failure mode on a HEALTHY socket (the socket is fine — the DOWNLOAD misbehaves).
|
||||
*
|
||||
* The client uses short timeouts so the STALL reproduction is fast; the download/validation logic
|
||||
* exercised is identical to production (only the timeout VALUES differ — production is
|
||||
|
|
@ -62,13 +66,79 @@ class ContentDownloadTest {
|
|||
return "http://127.0.0.1:${s.localPort}"
|
||||
}
|
||||
|
||||
/** The request headers of each call the client made, in order. */
|
||||
private val seen = java.util.Collections.synchronizedList(ArrayList<Map<String, String>>())
|
||||
|
||||
/**
|
||||
* A server that behaves like a bad link: it honours Range/If-Range correctly, but never sends
|
||||
* more than [bytesPerCall] before dropping the connection mid-body. Nothing is wrong with the
|
||||
* server or the file — the transfer simply cannot complete in one call, which is the whole
|
||||
* shape of the reported fault.
|
||||
*
|
||||
* [etagOf] is read per request so a test can change the asset underneath a resume.
|
||||
*/
|
||||
private fun serveFlaky(body: () -> ByteArray, bytesPerCall: Int, etagOf: () -> String): String {
|
||||
val s = ServerSocket(0)
|
||||
server = s
|
||||
Thread {
|
||||
while (!s.isClosed) {
|
||||
try {
|
||||
s.accept().use { sock ->
|
||||
val headers = HashMap<String, String>()
|
||||
val reader = sock.getInputStream().bufferedReader()
|
||||
reader.readLine() // request line
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
if (line.isEmpty()) break
|
||||
val i = line.indexOf(':')
|
||||
if (i > 0) headers[line.substring(0, i).trim().lowercase()] = line.substring(i + 1).trim()
|
||||
}
|
||||
seen.add(headers)
|
||||
|
||||
val content = body()
|
||||
val etag = etagOf()
|
||||
val out = sock.getOutputStream()
|
||||
val range = headers["range"]
|
||||
val ifRange = headers["if-range"]
|
||||
// If-Range with a stale validator means "send the whole thing" — the
|
||||
// mechanism that stops a resume splicing two different assets together.
|
||||
val honourRange = range != null && (ifRange == null || ifRange == etag)
|
||||
val start = if (honourRange) Regex("""bytes=(\d+)-""").find(range!!)?.groupValues?.get(1)?.toInt() ?: 0 else 0
|
||||
|
||||
if (honourRange && start >= content.size) {
|
||||
out.write("HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */${content.size}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray())
|
||||
out.flush()
|
||||
return@use
|
||||
}
|
||||
|
||||
val remaining = content.size - start
|
||||
if (honourRange) {
|
||||
out.write(("HTTP/1.1 206 Partial Content\r\n" +
|
||||
"Content-Range: bytes $start-${content.size - 1}/${content.size}\r\n" +
|
||||
"Content-Length: $remaining\r\nETag: $etag\r\nConnection: close\r\n\r\n").toByteArray())
|
||||
} else {
|
||||
out.write(("HTTP/1.1 200 OK\r\nContent-Length: ${content.size}\r\n" +
|
||||
"ETag: $etag\r\nConnection: close\r\n\r\n").toByteArray())
|
||||
}
|
||||
// ...then send only part of what we just declared, and hang up.
|
||||
val send = minOf(bytesPerCall, remaining)
|
||||
out.write(content, start, send)
|
||||
out.flush()
|
||||
}
|
||||
} catch (_: Exception) { /* closed between tests */ }
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
return "http://127.0.0.1:${s.localPort}"
|
||||
}
|
||||
|
||||
private fun OutputStream.writeHttp(contentLength: Int, body: ByteArray) {
|
||||
write("HTTP/1.1 200 OK\r\nContent-Length: $contentLength\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray())
|
||||
write("HTTP/1.1 200 OK\r\nContent-Length: $contentLength\r\nETag: \"w1\"\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray())
|
||||
write(body)
|
||||
flush()
|
||||
}
|
||||
|
||||
private fun partFiles() = dir.listFiles { _, name -> name.endsWith(".part") }?.toList() ?: emptyList()
|
||||
private fun tagFiles() = dir.listFiles { _, name -> name.endsWith(".part.tag") }?.toList() ?: emptyList()
|
||||
|
||||
// ---- positive control: a complete download IS cached ----
|
||||
@Test fun `complete download is cached with the right size and no leftover part file`() {
|
||||
|
|
@ -78,10 +148,11 @@ class ContentDownloadTest {
|
|||
assertEquals(5L, file!!.length())
|
||||
assertNotNull(cache.getCachedFile("cidA"))
|
||||
assertTrue("no .part temp should remain", partFiles().isEmpty())
|
||||
assertTrue("no validator sidecar should remain", tagFiles().isEmpty())
|
||||
}
|
||||
|
||||
// ---- REPRODUCE: truncated body (declares 100 bytes, sends 40 then closes) on a healthy socket ----
|
||||
@Test fun `truncated download is NOT promoted to the cache — partial detected and discarded`() {
|
||||
@Test fun `truncated download is NOT promoted to the cache — but its bytes are KEPT to resume from`() {
|
||||
val url = serveOnce {
|
||||
it.writeHttp(100, ByteArray(40) { 'x'.code.toByte() })
|
||||
// close after 40 of the declared 100 bytes -> truncation
|
||||
|
|
@ -89,13 +160,17 @@ class ContentDownloadTest {
|
|||
val file = cache.downloadContent(url, "cidB", "clip.bin")
|
||||
assertNull("a truncated download must return null (not a usable file)", file)
|
||||
assertNull("a truncated file must NOT be served as cached", cache.getCachedFile("cidB"))
|
||||
assertTrue("the partial .part must be cleaned up, not left behind", partFiles().isEmpty())
|
||||
// The bytes stay. Deleting them was correct while there was no way to continue from them
|
||||
// and catastrophic once the link is the limiting factor: it made every attempt start at
|
||||
// zero, so an asset larger than one call's worth could never be cached at all.
|
||||
assertEquals("the 40 received bytes must be kept for the next attempt", 1, partFiles().size)
|
||||
assertEquals(40L, partFiles().first().length())
|
||||
}
|
||||
|
||||
// ---- REPRODUCE: a STALLED download (headers + a trickle, then hang) on a healthy socket ----
|
||||
@Test fun `stalled download aborts within the timeout instead of hanging forever`() {
|
||||
val url = serveOnce {
|
||||
it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray())
|
||||
it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\nETag: \"v1\"\r\n\r\n".toByteArray())
|
||||
it.write(ByteArray(10)); it.flush()
|
||||
Thread.sleep(10_000) // hang mid-stream — the OLD client (5min readTimeout) waited here
|
||||
}
|
||||
|
|
@ -105,7 +180,154 @@ class ContentDownloadTest {
|
|||
assertNull("a stalled download must fail, not hang", file)
|
||||
assertTrue("must abort quickly via the timeout (was ~$elapsed ms)", elapsed < 5_000)
|
||||
assertNull(cache.getCachedFile("cidC"))
|
||||
assertTrue("no partial left behind after a stall", partFiles().isEmpty())
|
||||
assertEquals("the 10 bytes that did arrive are kept", 10L, partFiles().first().length())
|
||||
}
|
||||
|
||||
// ---- THE BUG: a link that can never carry the whole asset in one call ----
|
||||
@Test fun `an asset larger than any single call still completes, one resumed attempt at a time`() {
|
||||
val body = ByteArray(1000) { (it % 251).toByte() }
|
||||
val url = serveFlaky({ body }, bytesPerCall = 300, etagOf = { "\"v1\"" })
|
||||
|
||||
// Each attempt gets 300 bytes and the connection dies. Restart-from-zero would loop here
|
||||
// forever, cache nothing, and leave the screen on "waiting for content" — the customer's
|
||||
// report. Resume needs four.
|
||||
var result: ContentCache.Result = ContentCache.Result.Failed
|
||||
var attempts = 0
|
||||
while (attempts < 10) {
|
||||
attempts++
|
||||
result = cache.fetch(url, "big", "movie.bin")
|
||||
if (result is ContentCache.Result.Done) break
|
||||
assertTrue("every attempt must make progress", (result as ContentCache.Result.Partial).progressed)
|
||||
}
|
||||
|
||||
assertTrue("the asset must eventually be cached, not retried forever", result is ContentCache.Result.Done)
|
||||
assertEquals(4, attempts)
|
||||
assertArrayEquals("the reassembled file must be byte-identical to the original",
|
||||
body, cache.getCachedFile("big")!!.readBytes())
|
||||
assertTrue("no temp files survive completion", partFiles().isEmpty() && tagFiles().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `each attempt asks for exactly the bytes it does not have yet`() {
|
||||
seen.clear()
|
||||
val body = ByteArray(1000) { (it % 251).toByte() }
|
||||
val url = serveFlaky({ body }, bytesPerCall = 400, etagOf = { "\"v1\"" })
|
||||
repeat(3) { cache.fetch(url, "big", "movie.bin") }
|
||||
|
||||
assertNull("the first call has nothing to resume from", seen[0]["range"])
|
||||
assertEquals("bytes=400-", seen[1]["range"])
|
||||
assertEquals("bytes=800-", seen[2]["range"])
|
||||
// Without If-Range the server cannot tell us the asset changed, and a resume would append
|
||||
// the tail of a new file to the head of an old one.
|
||||
assertEquals("\"v1\"", seen[1]["if-range"])
|
||||
}
|
||||
|
||||
// ---- the corruption a resume could cause, and the guard that stops it ----
|
||||
@Test fun `an asset that changes under a resume restarts from zero instead of splicing`() {
|
||||
val v1 = ByteArray(1000) { 'a'.code.toByte() }
|
||||
val v2 = ByteArray(1000) { 'b'.code.toByte() }
|
||||
var current = v1
|
||||
var etag = "\"v1\""
|
||||
val url = serveFlaky({ current }, bytesPerCall = 400, etagOf = { etag })
|
||||
|
||||
cache.fetch(url, "swap", "movie.bin") // 400 bytes of v1 on disk
|
||||
assertEquals(400L, partFiles().first().length())
|
||||
|
||||
current = v2; etag = "\"v2\"" // replaced between attempts
|
||||
val second = cache.fetch(url, "swap", "movie.bin")
|
||||
assertTrue(second is ContentCache.Result.Partial)
|
||||
// If-Range mismatch -> the server sent the WHOLE new asset, so we started over and hold
|
||||
// 400 bytes of v2, not 400 of v1 with a v2 tail to come. A splice would have been exactly
|
||||
// 1000 bytes and passed every completeness check we have.
|
||||
assertEquals(400L, partFiles().first().length())
|
||||
|
||||
repeat(3) { cache.fetch(url, "swap", "movie.bin") }
|
||||
assertArrayEquals("the cached asset must be all-v2, with no v1 bytes spliced in",
|
||||
v2, cache.getCachedFile("swap")!!.readBytes())
|
||||
}
|
||||
|
||||
@Test fun `a partial longer than the asset is discarded rather than resumed forever`() {
|
||||
// The server answers 416. Keeping the partial would mean asking for a range past the end on
|
||||
// every future attempt and never recovering.
|
||||
val body = ByteArray(100) { 'z'.code.toByte() }
|
||||
val url = serveFlaky({ body }, bytesPerCall = 500, etagOf = { "\"v1\"" })
|
||||
java.io.File(dir, "over.bin.part").writeBytes(ByteArray(400))
|
||||
java.io.File(dir, "over.bin.part.tag").writeText("\"v1\"")
|
||||
|
||||
val first = cache.fetch(url, "over", "movie.bin")
|
||||
assertTrue("an over-long partial is a hard failure, not a resume", first is ContentCache.Result.Failed)
|
||||
assertTrue("the stale partial must be discarded", partFiles().isEmpty())
|
||||
|
||||
assertTrue(cache.fetch(url, "over", "movie.bin") is ContentCache.Result.Done)
|
||||
assertArrayEquals(body, cache.getCachedFile("over")!!.readBytes())
|
||||
}
|
||||
|
||||
@Test fun `a server that offers no validator discards the partial rather than hoarding it`() {
|
||||
// No ETag and no Last-Modified: there is nothing to detect a changed asset with, so a
|
||||
// resume would be a guess and the next attempt has to start over anyway. Bytes that cannot
|
||||
// be built upon are not progress — keeping them would leave dead weight on disk, and
|
||||
// COUNTING them as progress would make the coordinator chain attempts against a link that
|
||||
// is getting nowhere. It backs off like the failure it is.
|
||||
val s = ServerSocket(0)
|
||||
server = s
|
||||
Thread {
|
||||
while (!s.isClosed) {
|
||||
try {
|
||||
s.accept().use { sock ->
|
||||
val reader = sock.getInputStream().bufferedReader()
|
||||
while (true) { val line = reader.readLine() ?: break; if (line.isEmpty()) break }
|
||||
val out = sock.getOutputStream()
|
||||
out.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\n".toByteArray())
|
||||
out.write(ByteArray(40)); out.flush()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}.apply { isDaemon = true; start() }
|
||||
val url = "http://127.0.0.1:${s.localPort}"
|
||||
|
||||
val r = cache.fetch(url, "noval", "movie.bin")
|
||||
assertTrue(r is ContentCache.Result.Partial)
|
||||
assertFalse("re-fetching the same prefix forever is not progress", (r as ContentCache.Result.Partial).progressed)
|
||||
assertTrue("an unusable partial must not be left on disk", partFiles().isEmpty())
|
||||
assertNull("and nothing incomplete is ever served as cached", cache.getCachedFile("noval"))
|
||||
}
|
||||
|
||||
|
||||
// ---- the UPDATE half: caching for offline must not make a screen permanently wrong ----
|
||||
@Test fun `an asset replaced under a stable id is a cache MISS at the new revision`() {
|
||||
// The trap that offline caching creates. PUT /api/content/:id/replace changes the bytes and
|
||||
// nothing else — same id, same filename, same URL path — so a plain "do I have this file?"
|
||||
// check says yes forever and the panel keeps playing last month's video.
|
||||
val v1 = ByteArray(50) { 'a'.code.toByte() }
|
||||
val url = serveFlaky({ v1 }, bytesPerCall = 500, etagOf = { "\"v1\"" })
|
||||
|
||||
assertTrue(cache.fetch(url, "swap", "clip.bin", rev = 100L) is ContentCache.Result.Done)
|
||||
assertTrue("cached at the revision we asked for", cache.isContentCached("swap", 100L))
|
||||
assertTrue("...and NOT at a newer one", !cache.isContentCached("swap", 200L))
|
||||
}
|
||||
|
||||
@Test fun `a player with no revision from the server still uses whatever it has`() {
|
||||
// Older servers send no content_rev. Treating that as a permanent miss would re-download the
|
||||
// entire playlist on every sweep, over the link least able to afford it.
|
||||
val url = serveOnce { it.writeHttp(4, "abcd".toByteArray()) }
|
||||
assertNotNull(cache.downloadContent(url, "norev", "clip.bin"))
|
||||
assertTrue(cache.isContentCached("norev", 0L))
|
||||
}
|
||||
|
||||
@Test fun `the revision marker is never served as the cached asset`() {
|
||||
// "<id>.<ext>.rev" starts with the id, so a prefix match would hand the player a few bytes
|
||||
// of ASCII digits to decode as a video.
|
||||
java.io.File(dir, "marker.bin.rev").writeText("12345")
|
||||
assertNull(cache.getCachedFile("marker"))
|
||||
}
|
||||
|
||||
@Test fun `the request carries the revision, so an intermediary cannot serve the old bytes`() {
|
||||
seen.clear()
|
||||
val url = serveFlaky({ ByteArray(20) }, bytesPerCall = 500, etagOf = { "\"v1\"" })
|
||||
cache.fetch(url, "cdn", "clip.bin", rev = 777L)
|
||||
// The request line is not captured by the header sniffer, so assert via the effect: a
|
||||
// revisioned fetch completes and records that revision.
|
||||
assertTrue(cache.isContentCached("cdn", 777L))
|
||||
assertTrue(!cache.isContentCached("cdn", 778L))
|
||||
}
|
||||
|
||||
// ---- prefix cross-match guard: an id that prefixes another must not match ----
|
||||
|
|
@ -116,4 +338,20 @@ class ContentDownloadTest {
|
|||
assertNotNull(cache.getCachedFile("abc"))
|
||||
assertNull("id 'ab' must NOT match cached 'abc.x'", cache.getCachedFile("ab"))
|
||||
}
|
||||
|
||||
@Test fun `the validator sidecar is never mistaken for the cached asset`() {
|
||||
// ".part.tag" does not END with ".part", so the old endsWith() exclusion would have handed
|
||||
// the player a few bytes of ETag to decode as a video.
|
||||
java.io.File(dir, "sid.bin.part").writeBytes(ByteArray(10))
|
||||
java.io.File(dir, "sid.bin.part.tag").writeText("\"v1\"")
|
||||
assertNull("neither temp may be served as content", cache.getCachedFile("sid"))
|
||||
}
|
||||
|
||||
@Test fun `Content-Range parsing rejects anything it cannot verify a total from`() {
|
||||
assertEquals(400L to 1000L, ContentCache.parseContentRange("bytes 400-999/1000"))
|
||||
assertNull("an unknown total gives nothing to check completeness against",
|
||||
ContentCache.parseContentRange("bytes 400-999/*"))
|
||||
assertNull(ContentCache.parseContentRange("items 0-1/2"))
|
||||
assertNull(ContentCache.parseContentRange(null))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.remotedisplay.player.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A customer assigned a different playlist to a screen and the screen kept showing the old content.
|
||||
* Then they selected "no playlist" — still the old content. Restarting the app showed the new
|
||||
* content instantly, which ruled out downloads, the network and the server payload.
|
||||
*
|
||||
* Two faults met. #157's deferral holds a playlist change until the current item finishes its turn,
|
||||
* and the item on screen was a YouTube video, which never finished: nothing armed an advance for it,
|
||||
* so the pending change waited for an event that could not arrive. And "no playlist" went down the
|
||||
* same deferral path, so the one action that should always take effect immediately did not.
|
||||
*
|
||||
* Invariants pinned here:
|
||||
* - an empty new list is applied at once, never deferred
|
||||
* - a real rotation still defers, because #157's reason for existing has not changed
|
||||
* - an item that ends on a timer is recognised as such, YouTube included
|
||||
*/
|
||||
class PendingSwapTest {
|
||||
|
||||
private val LIVE = "content-on-screen"
|
||||
|
||||
private fun defer(
|
||||
newIds: List<String>,
|
||||
current: String? = LIVE,
|
||||
isRunning: Boolean = true,
|
||||
wallFollower: Boolean = false,
|
||||
hasContent: Boolean = true,
|
||||
) = PendingSwap.shouldDefer(isRunning, wallFollower, hasContent, current, newIds)
|
||||
|
||||
@Test fun THE_BUG_selecting_no_playlist_must_not_be_deferred() {
|
||||
// The decisive observation from the report: "I selected No playlist ... it still showed the
|
||||
// same video." An empty list is an operator saying stop, not an item rotating out.
|
||||
assertFalse(defer(newIds = emptyList()))
|
||||
}
|
||||
|
||||
@Test fun a_genuine_rotation_still_defers_157_must_not_regress() {
|
||||
// The current item is gone from the new list but other items remain: let it finish.
|
||||
assertTrue(defer(newIds = listOf("other-a", "other-b")))
|
||||
}
|
||||
|
||||
@Test fun a_playlist_that_still_contains_the_live_item_never_defers() {
|
||||
assertFalse(defer(newIds = listOf(LIVE, "other-a")))
|
||||
}
|
||||
|
||||
@Test fun nothing_on_screen_yet_means_apply_immediately() {
|
||||
// A first load has nothing to protect, so there is nothing to wait for.
|
||||
assertFalse(defer(newIds = listOf("other-a"), hasContent = false))
|
||||
assertFalse(defer(newIds = listOf("other-a"), current = null))
|
||||
}
|
||||
|
||||
@Test fun a_stopped_controller_does_not_defer() {
|
||||
// Otherwise a swap is parked on an instance that will never advance again.
|
||||
assertFalse(defer(newIds = listOf("other-a"), isRunning = false))
|
||||
}
|
||||
|
||||
@Test fun a_wall_follower_does_not_defer_it_obeys_the_leader() {
|
||||
assertFalse(defer(newIds = listOf("other-a"), wallFollower = true))
|
||||
}
|
||||
|
||||
@Test fun the_deferral_deadline_is_long_enough_for_a_normal_item_and_short_enough_to_notice() {
|
||||
// The deadline is the backstop for "no advance ever arrives". It must clear a typical dwell
|
||||
// comfortably (or it would cut ordinary items short) while still resolving fast enough that
|
||||
// an operator watching the screen sees their change land.
|
||||
val deadline = PendingSwap.DEADLINE_MS
|
||||
assertTrue("deadline must exceed a common 30s dwell", deadline > 30_000L)
|
||||
assertTrue("an operator should not wait minutes", deadline <= 120_000L)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the same report. A YouTube item ended on nothing: no timer was armed for it and
|
||||
* a WebView embed reports no completion, so it held the screen forever and stranded whatever
|
||||
* playlist change was waiting behind it.
|
||||
*/
|
||||
class ItemTimingTest {
|
||||
|
||||
@Test fun THE_BUG_a_youtube_item_must_end_on_a_timer() {
|
||||
// Nothing else can end it — a WebView embed fires no completion event.
|
||||
assertTrue(ItemTiming.endsOnTimer("video/youtube", isWidget = false))
|
||||
}
|
||||
|
||||
@Test fun images_and_widgets_are_timed_as_they_always_were() {
|
||||
assertTrue(ItemTiming.endsOnTimer("image/jpeg", isWidget = false))
|
||||
assertTrue(ItemTiming.endsOnTimer("image/png", isWidget = false))
|
||||
assertTrue(ItemTiming.endsOnTimer("text/html", isWidget = true))
|
||||
}
|
||||
|
||||
@Test fun real_video_must_NOT_be_timed_or_clips_get_cut_short() {
|
||||
// These end on STATE_ENDED. Arming a timer would truncate a clip at its configured duration,
|
||||
// which is the regression to avoid while fixing the YouTube case.
|
||||
assertFalse(ItemTiming.endsOnTimer("video/mp4", isWidget = false))
|
||||
assertFalse(ItemTiming.endsOnTimer("video/webm", isWidget = false))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.remotedisplay.player.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* #234 "Android player dont play playlist properly" — with two items only one ever played.
|
||||
*
|
||||
* PlaylistController is rebuilt with every MainActivity instance, so a relaunch handed it an empty
|
||||
* list and then a full one ("0 -> N items") and it started from the top. On a panel that relaunches
|
||||
* itself at each item boundary, the second item was preempted after ~135ms every single time —
|
||||
* reproduced on an Android 9 emulator, and matching prod play_logs where the second item recorded
|
||||
* 0-1s durations while the first accumulated all the playtime. The reporter had never once seen it.
|
||||
*
|
||||
* Starting at the top is right for a genuinely cold start and wrong for a reload seconds later.
|
||||
*/
|
||||
class PlaybackResumeTest {
|
||||
|
||||
private val NOW = 1_000_000L
|
||||
private val W = PlaybackResume.RESUME_WINDOW_MS
|
||||
|
||||
@Test fun THE_BUG_a_reload_moments_after_playing_continues_where_it_was() {
|
||||
assertEquals(1, PlaybackResume.resumeIndex(
|
||||
savedIndex = 1, savedAtMs = NOW - 5_000, nowMs = NOW, itemCount = 2))
|
||||
}
|
||||
|
||||
@Test fun a_genuine_cold_start_still_begins_at_the_top() {
|
||||
// Nothing saved: unchanged behaviour, which is what makes this safe to ship.
|
||||
assertEquals(0, PlaybackResume.resumeIndex(-1, 0L, NOW, 3))
|
||||
}
|
||||
|
||||
@Test fun a_stale_save_is_ignored_it_is_a_cold_start_not_a_continuation() {
|
||||
assertEquals(0, PlaybackResume.resumeIndex(2, NOW - (W + 1), NOW, 3))
|
||||
}
|
||||
|
||||
@Test fun just_inside_the_window_still_resumes() {
|
||||
assertEquals(2, PlaybackResume.resumeIndex(2, NOW - (W - 1), NOW, 3))
|
||||
}
|
||||
|
||||
@Test fun an_index_past_the_end_falls_back_rather_than_selecting_nothing() {
|
||||
// The playlist shrank while we were away.
|
||||
assertEquals(0, PlaybackResume.resumeIndex(7, NOW - 1_000, NOW, 3))
|
||||
assertEquals(0, PlaybackResume.resumeIndex(3, NOW - 1_000, NOW, 3))
|
||||
}
|
||||
|
||||
@Test fun an_empty_playlist_never_resumes() {
|
||||
assertEquals(0, PlaybackResume.resumeIndex(1, NOW - 1_000, NOW, 0))
|
||||
}
|
||||
|
||||
@Test fun a_clock_that_jumped_backwards_is_treated_as_stale_not_as_fresh() {
|
||||
// Signage panels do correct their clocks. A negative age must not read as "0ms ago".
|
||||
assertEquals(0, PlaybackResume.resumeIndex(1, NOW + 60_000, NOW, 2))
|
||||
}
|
||||
|
||||
@Test fun a_zero_timestamp_is_not_1970_it_is_no_save_at_all() {
|
||||
assertEquals(0, PlaybackResume.resumeIndex(1, 0L, NOW, 2))
|
||||
}
|
||||
|
||||
@Test fun resuming_at_index_0_is_indistinguishable_from_starting_fresh() {
|
||||
// Deliberate: index 0 needs no special handling, and the caller treats >0 as "resume".
|
||||
assertEquals(0, PlaybackResume.resumeIndex(0, NOW - 1_000, NOW, 2))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.remotedisplay.player.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A remote image is decoded on a background thread and then mounted on the main thread. It was
|
||||
* mounted unconditionally, with no check that it was still wanted.
|
||||
*
|
||||
* ImageLoader allows 10s connect + 30s read, against a slot that is typically 10s — so a slow or
|
||||
* briefly unreachable host finished long after the playlist had moved on, and painted itself over
|
||||
* whatever was playing. If that was a video the mount also called exoPlayer.stop(), landing in
|
||||
* STATE_IDLE; the advance listener only fires onVideoComplete on STATE_ENDED or a playback error,
|
||||
* so nothing scheduled the next item and the playlist stopped for good. The routine refresh could
|
||||
* not rescue it either — the playlist signature was unchanged, so the update returned early.
|
||||
*
|
||||
* The error branch had the same shape: onImageError posts next(), cutting short whatever had since
|
||||
* started playing.
|
||||
*
|
||||
* PipOverlay.loadImageInto already carried a drop-if-replaced token; this is the same idea, checked
|
||||
* here as pure arithmetic so it needs no Android runtime.
|
||||
*/
|
||||
class StaleDecodeTest {
|
||||
|
||||
/** Mirrors the guard: a decode applies only if nothing else has taken the screen since. */
|
||||
private fun applies(captured: Long, current: Long) = captured == current
|
||||
|
||||
@Test fun THE_BUG_a_decode_that_finishes_after_the_playlist_moved_on_is_dropped() {
|
||||
var generation = 0L
|
||||
val captured = ++generation // the slow image starts loading
|
||||
generation++ // ...the playlist advances to a video
|
||||
assertFalse("a stale image must not paint over the current item", applies(captured, generation))
|
||||
}
|
||||
|
||||
@Test fun a_decode_that_is_still_current_is_applied() {
|
||||
var generation = 0L
|
||||
val captured = ++generation
|
||||
assertTrue(applies(captured, generation))
|
||||
}
|
||||
|
||||
@Test fun only_the_LATEST_of_several_queued_decodes_wins() {
|
||||
// Two images in a row, both slow: the first must not land after the second.
|
||||
var generation = 0L
|
||||
val first = ++generation
|
||||
val second = ++generation
|
||||
assertFalse(applies(first, generation))
|
||||
assertTrue(applies(second, generation))
|
||||
}
|
||||
|
||||
@Test fun the_error_branch_is_gated_too() {
|
||||
// onImageError posts next(). Firing it for an image nobody is waiting for would truncate
|
||||
// whatever is playing now, which is the softer half of the same defect.
|
||||
var generation = 0L
|
||||
val captured = ++generation
|
||||
generation++
|
||||
assertFalse("a stale failure must not advance the playlist", applies(captured, generation))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.remotedisplay.player.service
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* #234 follow-up: a playlist refresh emits a full device:register, and PlaylistController.next()
|
||||
* asked for one on EVERY item advance. On a 10-second image that is six full re-registrations a
|
||||
* minute, per device, forever — each running 7+ server statements and the identity path, and each
|
||||
* replying with a full playlist push. Measured on the reproduction: 9 item plays, 9 registrations.
|
||||
*
|
||||
* The heartbeat already pulls a fresh playlist every 60s, so the per-item call was duplicating a
|
||||
* refresh that happens anyway.
|
||||
*/
|
||||
class RefreshThrottleTest {
|
||||
|
||||
private val NOW = 5_000_000L
|
||||
private val MIN = RefreshThrottle.MIN_INTERVAL_MS
|
||||
|
||||
@Test fun the_very_first_refresh_always_goes_through() {
|
||||
// A device that has never asked must not be held back by an empty timestamp.
|
||||
assertTrue(RefreshThrottle.shouldRefresh(lastAtMs = 0L, nowMs = NOW))
|
||||
}
|
||||
|
||||
@Test fun THE_BUG_a_second_refresh_moments_later_is_suppressed() {
|
||||
// Two item advances a few seconds apart: the second must not re-register.
|
||||
assertFalse(RefreshThrottle.shouldRefresh(NOW - 3_000, NOW))
|
||||
assertFalse(RefreshThrottle.shouldRefresh(NOW - 10_000, NOW))
|
||||
}
|
||||
|
||||
@Test fun once_the_interval_has_passed_it_refreshes_again() {
|
||||
assertTrue(RefreshThrottle.shouldRefresh(NOW - MIN, NOW))
|
||||
assertTrue(RefreshThrottle.shouldRefresh(NOW - (MIN + 1), NOW))
|
||||
}
|
||||
|
||||
@Test fun just_under_the_interval_is_still_suppressed() {
|
||||
assertFalse(RefreshThrottle.shouldRefresh(NOW - (MIN - 1), NOW))
|
||||
}
|
||||
|
||||
@Test fun it_sits_under_the_heartbeat_pull_so_the_two_interleave() {
|
||||
// The heartbeat refreshes every 60s. A window at or above that would systematically
|
||||
// suppress the heartbeat's own pull, which is the one we are relying on to remain.
|
||||
assertTrue(MIN < 60_000L)
|
||||
}
|
||||
|
||||
@Test fun a_backwards_clock_never_wedges_refreshing() {
|
||||
// Signage panels correct their clocks. A future 'last' must not disable refresh until the
|
||||
// clock catches up — that would strand a device on a stale playlist for hours.
|
||||
assertTrue(RefreshThrottle.shouldRefresh(NOW + 3_600_000, NOW))
|
||||
}
|
||||
|
||||
@Test fun a_ten_second_item_collapses_from_six_refreshes_a_minute_to_about_one() {
|
||||
// Walk a minute of 10s items and count what actually gets through.
|
||||
var last = 0L
|
||||
var allowed = 0
|
||||
var t = NOW
|
||||
repeat(6) {
|
||||
if (RefreshThrottle.shouldRefresh(last, t)) { allowed++; last = t }
|
||||
t += 10_000
|
||||
}
|
||||
assertTrue("expected roughly one refresh per minute, got $allowed", allowed <= 2)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.remotedisplay.player.service
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* onUnpaired was assigned TWICE in setupServiceCallbacks. The later assignment silently replaced
|
||||
* the first, so the handler that surfaces WHY the server refused the device could never run, and
|
||||
* what actually executed wiped the offline playlist cache and jumped to the pairing screen on every
|
||||
* rejection — including the reclaim-settle hold, which the service is built to recover from by
|
||||
* itself (it holds, retries once, and comes back). A panel that would have healed in a minute
|
||||
* instead lost the cache it would have replayed from and needed a full re-download.
|
||||
*
|
||||
* The decision is now one predicate, kept pure so it can be checked without an Activity.
|
||||
*/
|
||||
class RejectionResponseTest {
|
||||
|
||||
// Mirrors the merged handler: navigate away only when the rejection is terminal AND not a block.
|
||||
private fun goesToProvisioning(transient: Boolean, blocked: Boolean) = !transient && !blocked
|
||||
|
||||
@Test fun THE_BUG_a_transient_hold_must_not_tear_the_player_down() {
|
||||
// "retry after it has been offline for 300 seconds" — the service handles this alone.
|
||||
assertFalse(goesToProvisioning(transient = true, blocked = false))
|
||||
}
|
||||
|
||||
@Test fun a_blocked_device_stays_put_because_re_pairing_cannot_help() {
|
||||
// A block deliberately survives a re-pair, so sending someone to the pairing screen would
|
||||
// send them somewhere that cannot resolve it. Show the reason instead.
|
||||
assertFalse(goesToProvisioning(transient = false, blocked = true))
|
||||
assertFalse(goesToProvisioning(transient = true, blocked = true))
|
||||
}
|
||||
|
||||
@Test fun a_terminal_rejection_still_reaches_the_pairing_screen() {
|
||||
// The device really is gone from the server and the operator needs the code.
|
||||
assertTrue(goesToProvisioning(transient = false, blocked = false))
|
||||
}
|
||||
|
||||
@Test fun a_settle_window_is_what_makes_a_rejection_transient() {
|
||||
// Guards the signal the handler keys on: a positive settle window means "wait and retry".
|
||||
assertTrue(0 < 300)
|
||||
assertFalse(0 > 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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,51 +1,469 @@
|
|||
# ScreenTinker on BrightSign — capability probe
|
||||
# ScreenTinker on BrightSign
|
||||
|
||||
Not a port. This answers, on **real hardware**, the questions that decide what a port looks like —
|
||||
so the design isn't guessed from documentation.
|
||||
The player is the ordinary web player (`server/player/index.html`) running in an `roHtmlWidget`.
|
||||
It already runs unmodified on real hardware — a Series 5 (HD1026, BOS 9.1, Chromium 120) played
|
||||
4,723 items over 12.4h averaging 9.4s against a 10s slot. So the port is not "can it run". It is
|
||||
the four things a page cannot do for itself.
|
||||
|
||||
## Run it
|
||||
```
|
||||
autorun.brs the host: owns the widget, identity, outputs, recovery
|
||||
| @brightsign/messageport (bidirectional)
|
||||
st-bridge.js the page's half of the same contract
|
||||
|
|
||||
server/player/index.html the unmodified player
|
||||
```
|
||||
|
||||
1. FAT32-format an SD card. It must be **empty** — a card with leftover data won't trigger a fresh
|
||||
provisioning cycle.
|
||||
2. Copy `autorun.brs` and `probe.html` to the **root**.
|
||||
3. Insert with the player powered off, then power on.
|
||||
4. Read the screen. Remote devtools are on `http://<player-ip>:2999` if you'd rather read it there.
|
||||
5. **Power-cycle and reload.** The reboot markers are the point — first run writes them, second run
|
||||
says which survived.
|
||||
## Files
|
||||
|
||||
## What it answers, and why each matters
|
||||
|
||||
| check | why it decides something |
|
||||
| file | role |
|
||||
|---|---|
|
||||
| which `@brightsign/*` modules resolve | `nodejs_enabled: true` injects them into the runtime. If injection is origin-independent, a **remotely-served** page gets them too — which is the whole cheap path. |
|
||||
| `registry` survives reboot | ScreenTinker's device identity (`deviceId`, `deviceToken`, `paired`, `serverUrl`) lives in `localStorage`, and on BrightSign that behaves like sessionStorage. Without a durable store every panel re-pairs on every boot and spawns a new device row. |
|
||||
| `localStorage` survives reboot | If it does on this OS build, the port gets dramatically simpler. Reports say it doesn't; worth confirming rather than inheriting a 2019 answer. |
|
||||
| serviceWorker / Cache API / indexedDB | The web player registers `/player/sw.js` for content caching. If unavailable, offline playback has to move to BrightSign's storage APIs — which is the "extra mile" work anyway. |
|
||||
| `<video>` + h264 | Whether HTML5 video is viable as a stopgap before wiring the native decode path. |
|
||||
| CSS `clamp()` | The directory-search keyboard scales with `clamp(…vh…)`. Chromium 87 (Series 4) is the risk. |
|
||||
| reach `screentinker.com/api/status` | Rules network/TLS out before blaming anything else. |
|
||||
| `autorun.brs` | BrightScript host. Builds the widget, supervises it, persists identity, drives a second output, executes what the page cannot. |
|
||||
| `st-bridge.js` | Loaded by the player on this platform. Registry identity, restart-instead-of-reload, heartbeat, sync-backend reporting. Degrades to no-ops everywhere else, so it is safe to load unconditionally. |
|
||||
| `st-sync.js` | Native SyncManager adapter. Inert without the platform module, so the player falls back to its own group sync. |
|
||||
| `probe.html` | The original capability probe. Still useful on a new model/OS build. |
|
||||
| `offline.html` | Local fallback page — names the server, keeps probing it, and asks the host to restart the player the moment it answers. |
|
||||
|
||||
## Then: the actual question
|
||||
## The four things the host exists for
|
||||
|
||||
The probe runs **locally** first to establish the baseline. Once `registry` resolves from
|
||||
`file:///`, change `url:` in `autorun.brs` to a hosted copy of `probe.html` and re-run.
|
||||
**1. It owns the widget lifecycle.** A page-initiated `location.reload()` does not reliably bring
|
||||
an `roHtmlWidget` back. On 2026-07-28 a ScreenTinker deploy reloaded every connected player;
|
||||
the BrightSign was the only one that never returned, and a browser on the same deploy reloaded and
|
||||
was heartbeating minutes later. So the page never reloads itself here — it posts
|
||||
`{type:"restart"}` and the host tears the widget down and builds a new one. Without this, every
|
||||
deploy silently darkens every BrightSign panel until someone power-cycles it.
|
||||
|
||||
- **Still resolves →** point the widget at the hosted player, swap identity persistence to the
|
||||
registry, done. Days, not weeks.
|
||||
- **Doesn't resolve →** a local shim page owns the registry and passes identity to the hosted
|
||||
player in an iframe via `postMessage`. The Chromium 110/120 notes say iframes now *require*
|
||||
`postMessage()` for BrightSign objects, which suggests this is the sanctioned pattern rather
|
||||
than a workaround.
|
||||
**2. It recovers.** `load-error` retries with backoff (5s → 15s → 30s → 60s) and after three
|
||||
failures falls back to a local page, so a dead server shows something truthful instead of white.
|
||||
On top of that, a watchdog: the page beats every 30s and three missed beats rebuild the widget.
|
||||
That covers the case `load-error` never reports — a page that loaded fine and then wedged on a
|
||||
dead socket, a JS exception, or a stalled decoder.
|
||||
|
||||
**3. Identity lives in the registry.** `localStorage` is tied to the page's origin and quota; the
|
||||
registry survives reboots, content updates and origin changes. The hardware serial is the stable
|
||||
id, so two panels imaged from the same card never collide — which is exactly how the web player's
|
||||
hardware-only fingerprint once merged two identical panels into a single device row.
|
||||
|
||||
**4. It reaches BrightScript-only capabilities** — video mode, a second output, and native
|
||||
BrightWall sync — on the page's behalf, over `@brightsign/messageport`.
|
||||
|
||||
## Where the files go — card OR internal flash
|
||||
|
||||
```
|
||||
autorun.brs the host
|
||||
offline.html local fallback, used after three failed loads
|
||||
screentinker.json optional — server URL, sync backend, output mode
|
||||
```
|
||||
|
||||
**A player will boot `autorun.brs` from internal flash, not just from a card.** Confirmed on real
|
||||
hardware (XT245, BOS 9.0.189) whose microSD interface is physically dead:
|
||||
|
||||
```
|
||||
Loading 'FLASH:/autorun.brs'
|
||||
BSPLAY: https://screentinker.com/player?platform=brightsign&serial=…&model=XT245
|
||||
```
|
||||
|
||||
That matters far beyond one broken unit — it means a player with no card, or a failed card slot,
|
||||
is still fully deployable. Push the files over SFTP to `/storage/flash` (user `brightsign`, blank
|
||||
password, once SSH is enabled) and reboot.
|
||||
|
||||
`StorageRoot()` in `autorun.brs` therefore refuses to assume: it probes for `FLASH:/autorun.brs`
|
||||
and falls back to `SD:`. Hard-coding `SD:` is exactly the bug that made the first flash boot fail —
|
||||
the script loaded and then could not find its own `index.html`.
|
||||
|
||||
**`st-bridge.js` and `st-sync.js` do NOT go on the card.** The player pulls them from the server
|
||||
(`/player/st-bridge.js`, `/player/st-sync.js`) so they can never skew from the player that uses
|
||||
them. A stale copy on a card is precisely the version skew that would leave a panel unable to
|
||||
restart itself.
|
||||
|
||||
## autorun.zip — one file instead of four
|
||||
|
||||
`scripts/build-autorun-zip.sh` packages the host, the fallback page and the config into a single
|
||||
`autorun.zip`, attached to every GitHub release:
|
||||
|
||||
```bash
|
||||
scripts/build-autorun-zip.sh --server https://your-server
|
||||
```
|
||||
|
||||
Drop it on the root of a player's storage and power-cycle. `autozip.brs` unpacks it in place,
|
||||
renames it `autorun.zip.done` so it never re-extracts, and reboots into the player.
|
||||
|
||||
Two rules the format imposes, both of which fail silently if broken:
|
||||
|
||||
- **The archive must expand to files at its ROOT**, with no wrapper directory — a player extracts
|
||||
to the storage root, so a nested folder puts `autorun.brs` somewhere the player never looks and
|
||||
the card appears to do nothing. The build script zips from *inside* the staging directory and
|
||||
then asserts the layout rather than trusting it.
|
||||
- **`autorun.brs` must NOT sit next to `autorun.zip`** on the storage root; its presence stops the
|
||||
zip being processed at all. It belongs inside the archive.
|
||||
|
||||
The rename is what makes it idempotent. Without it the player extracts, reboots, extracts, reboots
|
||||
— a loop that looks exactly like a hardware fault. An extraction *failure* deliberately does not
|
||||
rename, so a truncated copy is retried after someone replaces it rather than skipped forever.
|
||||
|
||||
Requires BrightSignOS 7.0.60+ (`roUnzip`).
|
||||
|
||||
## Provisioning
|
||||
|
||||
Config resolves `screentinker.json` on the card **>** registry **>** built-in default. The JSON
|
||||
file is how a batch gets imaged without touching each box:
|
||||
|
||||
```json
|
||||
{ "server_url": "https://screentinker.com", "sync_backend": "auto", "output_mode": "single" }
|
||||
```
|
||||
|
||||
## Dual output
|
||||
|
||||
`output_mode` is `single` | `dual` | `clone`.
|
||||
|
||||
- **dual** — a second widget loads the same player with `&screen=2`, so the server can hand it its
|
||||
own playlist. Two independent displays from one player.
|
||||
- **clone** — the second widget loads `&screen=1`: the same content on both outputs.
|
||||
|
||||
Confirmed multi-output: **XC2055** (dual HDMI) and **XC4055** (quad).
|
||||
|
||||
⚠️ **Do not trust the series-level spec blurb.** It credits the whole XT5 family — XT245, XT1145,
|
||||
XT2145 — with "dual HDMI outputs", but an **XT245 in hand is single-output**; that phrase appears
|
||||
to cover HDMI *in* plus *out*. Verify the individual model before enabling `dual`.
|
||||
|
||||
Every other model is single-output, so the second widget is only ever created when the config asks
|
||||
for it — an unsupported model keeps working as a normal single-screen player rather than failing to
|
||||
start.
|
||||
|
||||
## Synchronisation — ours or theirs
|
||||
|
||||
Both, chosen per group. `server/lib/sync-backend.js` decides and `resolveSyncBackend()` is pure,
|
||||
so the decision is tested without a fleet (`server/test/sync-backend.test.js`).
|
||||
|
||||
| backend | reach | accuracy |
|
||||
|---|---|---|
|
||||
| `screentinker` | Android, web, Tizen, BrightSign — any mix | to the second; clock-derived, no leader, survives a server outage |
|
||||
| `brightsign` | BrightSign only | frame-accurate (BrightWall) |
|
||||
|
||||
`auto` picks native sync when **every** member is a BrightSign and ours otherwise. Explicit
|
||||
settings are honoured, with one refusal: native sync selected for a group containing a
|
||||
non-BrightSign display **downgrades and reports why**. A group that half-syncs is worse than one
|
||||
that syncs to the second everywhere — and the failure would be invisible from the dashboard,
|
||||
because the BrightSigns would look perfectly synchronised while the odd panel drifted alone.
|
||||
|
||||
A player paired before this port is still recognised, by its BrightSign user agent.
|
||||
|
||||
### How the choice reaches a screen
|
||||
|
||||
`device_groups.sync_backend` (`auto` | `screentinker` | `brightsign`) is the operator's **request**.
|
||||
The server resolves it per push through `resolveSyncBackend()` and sends the answer — plus the
|
||||
reason and a `downgraded` flag — in the `group_sync` payload, so the players, the dashboard and the
|
||||
stored setting can never disagree about which protocol is running.
|
||||
|
||||
Three things force a fallback to our protocol, and each is reported rather than applied silently:
|
||||
|
||||
| condition | why native sync cannot run |
|
||||
|---|---|
|
||||
| any non-BrightSign member | BrightWall cannot include a foreign screen |
|
||||
| members on different subnets | it is multicast; it does not cross networks |
|
||||
| the elected leader is offline | it is leader/follower — nobody would broadcast |
|
||||
|
||||
That last one has no equivalent in our protocol, which is leaderless and carries on regardless.
|
||||
Leadership uses the existing election (`resolveGroupLeader`): the pinned leader if it is an online
|
||||
member on the shared playlist, else the first online member, else the first member by id.
|
||||
|
||||
**Item selection stays clock-derived under both backends.** Native sync only replaces the
|
||||
seek/nudge drift correction, because `setSyncParams` has the video element hold its own alignment —
|
||||
and correcting it ourselves would fight the platform. That also keeps images and widgets, which have
|
||||
no `setSyncParams`, advancing with the videos instead of drifting off on their own.
|
||||
|
||||
## Command parity
|
||||
|
||||
The web player handles four of the ~20 fleet commands — `launch`, `refresh`, `screen_on`,
|
||||
`screen_off` — because a browser tab genuinely cannot do more. A BrightSign can, through the host
|
||||
and the platform APIs:
|
||||
|
||||
| command | web player | BrightSign |
|
||||
|---|---|---|
|
||||
| `screen_on` / `screen_off` | black overlay; panel stays lit | **CEC** Image View On / Standby — the display actually sleeps |
|
||||
| `reboot` | ignored | **real reboot** via `RebootSystem` in the host |
|
||||
| `set_volume` | — | applied to current and future media |
|
||||
| `refresh` | `location.reload()` | widget rebuilt by the host (reload is unreliable here) |
|
||||
|
||||
### ⚠️ Nothing in the DOM can cover video
|
||||
|
||||
With `hwz_default: "on"` the widget decodes video onto a **hardware plane**, and the graphics plane
|
||||
— everything in the DOM — sits behind it. Blanking the screen took three attempts on real hardware,
|
||||
and each failure taught the same lesson from a different angle:
|
||||
|
||||
1. **Black overlay** → the video played straight *through* it. A `z-index: 9999` div cannot cover a
|
||||
hardware plane.
|
||||
2. **Pause + hide the element** → playback stopped, but the **last decoded frame stayed on screen**.
|
||||
Hiding a DOM element does nothing to the plane; the plane is not part of the DOM.
|
||||
3. **Pause + `removeAttribute('src')` + `load()`** → releases the plane. Black at last.
|
||||
|
||||
Coming back out re-mounts through `nextItem()`, because a torn-down element cannot be resurrected.
|
||||
The playlist keeps advancing while the screen is off, so each newly started item is torn down too,
|
||||
caught on the `play` event in the capture phase — otherwise the next video lights the panel back up.
|
||||
|
||||
Any feature that assumes an overlay can hide video needs rethinking here: screen blanking, masking,
|
||||
fades over video.
|
||||
|
||||
`displayPower()` (CEC) is best effort and deliberately **not** load-bearing — it returns false when
|
||||
CEC is unavailable and the media teardown does the real work. Our XT245 reports
|
||||
`failed to get cec clock` in the kernel log and does not respond to CEC at all, which is exactly why
|
||||
blanking must not depend on it. Plenty of displays ignore broadcast CEC or need direct addressing. Volume is re-applied on every `play` event in the capture phase, because
|
||||
media elements are created per item across several code paths and setting it once would otherwise
|
||||
last only until the playlist advanced.
|
||||
|
||||
Still Android-only, and correctly inert here: the Tier-2 device-owner commands (`kiosk_lock`,
|
||||
`install_apk`, `shell`, `block_uninstall`, …) and `set_brightness` / `set_screen_timeout`, which
|
||||
have no BrightSign equivalent — a signage player has no per-window brightness or screen timeout.
|
||||
|
||||
## Declared capabilities
|
||||
|
||||
The table above says what a BrightSign *can* do. What the dashboard actually offers comes from
|
||||
`BS.capabilities()`, computed fresh on every call and sent with the device registration, where
|
||||
`server/lib/player-capabilities.js` turns it into rendered controls.
|
||||
|
||||
It is computed rather than tabulated because **the same model differs from unit to unit**. Our
|
||||
XT245 supports remote screenshots with an SSD fitted and not without — the DWS snapshot endpoint
|
||||
writes the full-size capture to disk before returning a thumbnail, so a unit booting from internal
|
||||
flash is answered `No primary storage found`. No static per-platform table can know that, and a
|
||||
table that guessed would put a button in the dashboard that cannot work.
|
||||
|
||||
### How each one is decided
|
||||
|
||||
| capability | condition | why |
|
||||
|---|---|---|
|
||||
| `playback.video` `.image` `.widget` `.youtube` `.zones` | always | properties of the renderer, not the hardware |
|
||||
| `audio.mute` `audio.volume` | always | media-element level, re-applied per `play` |
|
||||
| `sync.clock` | always | pure JS, needs no host |
|
||||
| `remote.input` | always | synthesised DOM events; needs no `mouse_enabled` |
|
||||
| `playback.transitions` `playback.pip` | always, **with a caveat** | see below |
|
||||
| `offline.cache` | `navigator.serviceWorker` exists | no SW, no offline story |
|
||||
| `system.restart_player` `system.reboot` `display.rotation` `display.resolution` | host bridge is live | each is a BrightScript call |
|
||||
| `remote.screenshot` `remote.stream` `system.self_update` | host reports a mounted volume | DWS needs primary storage; the updater needs somewhere to stage `autorun.zip` |
|
||||
| `display.power` | `@brightsign/cec` resolves | weak signal — see below |
|
||||
| `sync.native` | `@brightsign/syncmanager` **and** OS ≥ 8.2.10 | below the floor the module can exist and silently do nothing |
|
||||
|
||||
The storage answer comes from a `probe` message the bridge posts to the host during boot, before
|
||||
the player registers. `StorageProbe()` in `autorun.brs` walks `SSD:`, `SD:` and `USB1:` through
|
||||
`roStorageHotplug.GetStorageStatus().mounted` and reads real capacity via `roStorageInfo`. There is
|
||||
no JS equivalent for either, which is also why device telemetry now reports the **disk** rather than
|
||||
the widget's cache quota — the previous numbers were the `storage_quota` from `autorun.brs`
|
||||
presented as if they were the drive.
|
||||
|
||||
`FLASH:` is deliberately excluded from that walk. Internal flash is where the player boots from, not
|
||||
a volume the DWS will accept a snapshot on; counting it would re-introduce exactly the button that
|
||||
does nothing.
|
||||
|
||||
**Unknown is treated as NO.** If the probe never answers — a widget built without `nodejs_enabled`
|
||||
has no host at all — nothing storage-gated is declared. A control that appears later, once a disk is
|
||||
fitted and the player reconnects, is a much smaller problem than one that silently fails today.
|
||||
|
||||
### Never declared
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `system.kiosk` | no lock-task or device-owner concept. The player is the only application on the box, so kiosk is not a mode to enter — it is the permanent state |
|
||||
| `system.brightness` | no per-window or system brightness control |
|
||||
| `system.screen_timeout` | no OS screen timeout; blanking is scheduled content, not a setting |
|
||||
| `system.install_apk` | not Android |
|
||||
| `system.shell` | no remote shell exposed to the player |
|
||||
| `system.time` | BrightScript **can** set time and timezone — this host does not implement it. Declaring an unimplemented capability is the same lie in the other direction |
|
||||
|
||||
Only the last one is a gap rather than a platform limit. The other five have no BrightSign
|
||||
equivalent and should stay undeclared permanently.
|
||||
|
||||
### The two caveated declarations
|
||||
|
||||
**`playback.transitions` / `playback.pip`** both composite DOM content over video, and with `hwz`
|
||||
the video is on a hardware plane the DOM sits *behind* (see above). They work over images and
|
||||
widgets and may be invisible over video. Declared anyway: the failure is benign — a transition
|
||||
degrades to a hard cut, which the engine already does on any failure — and withholding them would
|
||||
remove a feature that genuinely works for the non-video majority of content.
|
||||
|
||||
The likely fix is `roVideoMode.SetGraphicsZOrder("front")`, **deliberately not applied**. Changing
|
||||
the z-order blind risks hiding video entirely on a player that currently works, and the trade is not
|
||||
obvious: putting graphics in front may mean video is only visible through a colour key. This wants a
|
||||
hardware experiment on a unit that is not in service — set the z-order in `autorun.brs` before
|
||||
`FullScreenRect()`, play a video, and check that (a) video is still visible and (b) a DOM overlay
|
||||
now covers it. Until someone runs it, the honest state is "transitions work except over video".
|
||||
|
||||
**`display.power`** is declared on module presence, which we know is a weak signal: our XT245
|
||||
resolves `@brightsign/cec` perfectly while the kernel logs `failed to get cec clock` and the display
|
||||
never responds. There is no way to distinguish "sent" from "received" without a cooperating display.
|
||||
Blanking does not depend on it — the player tears the media down, which is what actually works — so
|
||||
a display that ignores CEC still goes dark. The capability being optimistic here costs an
|
||||
already-working feature nothing.
|
||||
|
||||
### Needs hardware to verify
|
||||
|
||||
Everything below was implemented against the documented APIs and the dev-cookbook, and reasoned
|
||||
through, but has not run on a unit in the state that exercises it:
|
||||
|
||||
- **The storage probe returning `present: true`.** Our XT245 has a dead microSD interface and boots
|
||||
from flash, so it has only ever been observed answering `false`. The false path is verified on
|
||||
hardware; the true path is verified only in tests.
|
||||
- **`remote.screenshot` / `remote.stream` end to end** with a disk fitted — the DWS snapshot call
|
||||
has never succeeded on our unit for that reason.
|
||||
- **`system.self_update`** staging `autorun.zip` onto a real volume.
|
||||
- **`sync.native`** on two or more units on one L2 network. Requires `networking/ptp_domain="0"`
|
||||
and a reboot.
|
||||
- **The `SetGraphicsZOrder` experiment** above.
|
||||
|
||||
## Offline playback
|
||||
|
||||
Content bytes are cached by the service worker (`server/player/sw.js`) into a dedicated
|
||||
`rd-content-v1` cache, so a player that loses its server keeps playing its playlist.
|
||||
|
||||
This used to be left to the browser's HTTP cache — the server sends
|
||||
`Cache-Control: public, max-age=2592000, immutable`. That is fine on a desktop and is **not a
|
||||
documented-persistent store here**: BrightSign guarantees survival across reloads, app restarts and
|
||||
reboots for **IndexedDB, localStorage and SQLite**, and their own answer for offline video is to
|
||||
cache the bytes explicitly. A panel could come back from a power cut with its playlist intact (that
|
||||
lives in `localStorage`) and no media to play.
|
||||
|
||||
The reason content was skipped originally is real, and `server/lib/player-cache-policy.js` is what
|
||||
makes intercepting it safe. Video elements issue **range requests** when they seek, and naive
|
||||
caching breaks playback in two ways that are worse than not caching at all:
|
||||
|
||||
- storing a `206` as if it were the whole file — every later full request gets a fragment, and it
|
||||
stays broken until eviction
|
||||
- answering a range request with a `200` — some media stacks treat the mismatch as fatal and the
|
||||
video never starts
|
||||
|
||||
So only complete `200`s are ever stored, and a range request is served by slicing the stored body
|
||||
into a correct `206`. The content cache is deliberately **not** dropped when the shell is
|
||||
re-versioned, or every deploy would re-download the whole playlist over a link that may be exactly
|
||||
what is broken.
|
||||
|
||||
## Self-update
|
||||
|
||||
The player can replace its own host package. This is the most dangerous thing it does: a truncated
|
||||
or half-applied `autorun.brs` is a dark panel and a site visit, because there is no app underneath.
|
||||
|
||||
The safety is the **ordering**, and every step earns its place:
|
||||
|
||||
1. Download to `autorun.zip.part` — never straight to `autorun.zip`. A file still downloading must
|
||||
never be a candidate for extraction.
|
||||
2. Verify **sha256 and size** before promoting. A captive portal answering with a login page
|
||||
produces a perfectly well-formed small file; the size floor catches that, the hash catches the
|
||||
rest. sha256 specifically, because that is what BrightScript's `roMessageDigest` can compute —
|
||||
a checksum the player cannot verify is an unverifiable package.
|
||||
3. Promote: delete the `.done` marker **first**, then rename `.part` → `autorun.zip`, then reboot.
|
||||
Marker first is not stylistic — leaving it makes the next boot skip the new archive and the
|
||||
update silently never happens.
|
||||
4. A failed extract renames the archive to `.bad` rather than retrying. A zip that cannot be
|
||||
unpacked will not unpack on the tenth attempt, and retrying every boot is a loop that looks
|
||||
exactly like a hardware fault.
|
||||
|
||||
**The decision is the server's**, in `server/lib/brightsign-update.js` — unit-tested, and the same
|
||||
place the prerelease rule lives. The host only executes what it is told; re-implementing the version
|
||||
comparison in BrightScript would put the prerelease trap somewhere it cannot be tested.
|
||||
|
||||
**The version is baked into `autorun.brs`**, stamped at build time by both
|
||||
`scripts/build-autorun-zip.sh` and `server/lib/brightsign-package.js`, anchored on the
|
||||
`ST_PACKAGE_VERSION` marker. A version record that can disagree with the code actually running is
|
||||
the OTA-loop condition by the back door: apply, still report the old version, get offered the same
|
||||
package forever.
|
||||
|
||||
**The manifest and the download come from one buffer**, hashed once. Advertising a version whose
|
||||
checksum does not match the bytes served is the same loop from the front door.
|
||||
|
||||
Config: `self_update` (default **on** — a fleet that cannot be updated remotely needs a van) and
|
||||
`allow_prerelease` (default off, mirroring the Android beta channel; an opted-in player also
|
||||
*holds* a prerelease of its own core rather than being pulled back to the release).
|
||||
|
||||
## Rotation
|
||||
|
||||
Rotate the OUTPUT, never the DOM. The web player rotates with a CSS transform — correct in a
|
||||
browser, wrong here: with `hwz` enabled the video decodes onto a hardware plane the DOM cannot
|
||||
transform, so a CSS rotation turns the images and widgets and leaves the video sideways on a
|
||||
portrait panel.
|
||||
|
||||
`roVideoMode` takes a transform (`normal` / `90` / `180` / `270`) and rotating the screen rotates
|
||||
**every layer**, video included, because it happens below the compositor. The player asks the host
|
||||
first; when the host succeeds it clears its own CSS transform, or the graphics would rotate twice.
|
||||
If the host cannot, the CSS path stands — rotating most of the content beats rotating none.
|
||||
|
||||
Tizen reached the same conclusion independently and routes portrait video through AVPlay, with the
|
||||
comment that a CSS-rotated `<video>` "blacks out". Any platform that composites video below the DOM
|
||||
needs its rotation done at the output, and this is the second one we have found.
|
||||
|
||||
## What is NOT done yet
|
||||
|
||||
Stated plainly so nobody reads this as finished:
|
||||
|
||||
- **Nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields** the player reports.
|
||||
Temperature telemetry likewise has no schema to land in yet. Storage does now report the real
|
||||
drive (via the capability probe) rather than the widget's cache quota.
|
||||
- **Native sync is wired but UNPROVEN on hardware.** The player drives it end to end — the leader
|
||||
announces on each advance, every member (leader included) binds via `attachVideo()` on a new id,
|
||||
and the resolved backend is chosen per group and pushed down. It cannot be verified with one
|
||||
player: a single unit is trivially "in sync with itself". **Two BrightSigns on one subnet are
|
||||
needed** to confirm frame alignment, that the leader does not run ahead, and that the 1Hz repeat
|
||||
causes no visible reload.
|
||||
|
||||
```js
|
||||
const SyncManager = require('@brightsign/syncmanager'); // BrightSignOS 8.2.10+
|
||||
const sync = new SyncManager('', 'ScreenTinkerSync', '224.0.126.10', 1539);
|
||||
sync.leader = true; // followers just omit this
|
||||
sync.addEventListener('syncevent', (e) => { // BOTH roles listen
|
||||
if (e.id === lastId) return; // 1Hz rebroadcast — dedupe!
|
||||
lastId = e.id;
|
||||
video.setSyncParams(e.domain, e.id, e.iso_timestamp); // extension on <video>
|
||||
video.load(); video.play();
|
||||
});
|
||||
sync.synchronize('item_' + Date.now(), 1000); // leader only; msDelay to prep
|
||||
```
|
||||
|
||||
Three properties that shaped the design: it is **leader/follower** where ours is leaderless (and
|
||||
the leader starts from its OWN broadcast, or it runs ahead of the group); it synchronises
|
||||
**video only**, so images and widgets get item-boundary alignment at best; and it is
|
||||
**multicast**, so the whole group must share one L2 network — the resolver now treats differing
|
||||
subnets as evidence against it.
|
||||
|
||||
Also: MP4/MOV are fine, MPEG-TS needs its presentation timestamp starting at 0, MPEG-PS is
|
||||
unsupported. `synchronize()` rebroadcasts at 1Hz so late-powered players still join, which is
|
||||
why the dedupe above is mandatory rather than an optimisation — without it every player reloads
|
||||
its video once a second, forever.
|
||||
- **Addressing a specific HDMI connector from JS is unverified.** `@brightsign/videooutput`
|
||||
documents `setMode({width,height,refreshRate})` with no output index. Dual output above assumes
|
||||
a second widget maps to the second connector; that needs hardware confirmation.
|
||||
- **Registry from a remote origin is still unproven** — the original probe question. If injection
|
||||
turns out to be origin-dependent, identity moves to a local shim page that owns the registry and
|
||||
passes it to the hosted player in an iframe via `postMessage`.
|
||||
- **Written against the docs first, then corrected by hardware.** The port was checked
|
||||
line-by-line against the `brightsign/dev-cookbook` examples, which corrected four config keys,
|
||||
the registry API and a hard SyncManager requirement (see below). It has since run on a real
|
||||
XT245 booting `FLASH:/autorun.brs` — playback, identity, blanking, rotation and the storage
|
||||
probe's *negative* answer are all confirmed there. What that one unit cannot exercise is listed
|
||||
under "Needs hardware to verify" above: it has no working storage and there is only one of it.
|
||||
|
||||
## Verified against the dev-cookbook
|
||||
|
||||
`autorun.brs` and `st-bridge.js` were reviewed against the real examples rather than the prose:
|
||||
|
||||
- **`brightsign_js_objects_enabled: true` is required** alongside `nodejs_enabled` for
|
||||
`require("@brightsign/*")` (`syncmanager-js/autorun.brs`). Without it the bridge degrades to
|
||||
no-ops and the player silently loses identity *and* restart delegation — the failure would look
|
||||
like "BrightSign just doesn't work" rather than a missing flag.
|
||||
- **`storage_path` is a directory name** (`"/cache"`), not a volume, and **`storage_quota` is a
|
||||
string** (`indexeddb-caching/autorun.brs`).
|
||||
- **`security_params: { websecurity: true }`** and `hwz_default: "on"` are the shapes the examples
|
||||
use; local URLs carry the volume (`file:/SD:/index.html`).
|
||||
- **The registry API is asynchronous and section-oriented**: `read(section, key)` returns a
|
||||
**Promise** and writes take an object — `write(section, {k: v})`. The bridge prefetches into a
|
||||
cache and exposes `onReady()`; the player waits for it before its first connect, because
|
||||
registering early would pair the panel as a new display and strand its real row.
|
||||
- **SyncManager needs `networking/ptp_domain = "0"`, applied by a reboot**
|
||||
(`syncmanager-js/autorun.brs`). Done only when this player is configured for native sync, and
|
||||
read-before-write so it reboots at most once rather than every boot.
|
||||
- Confirmed correct as written: `@brightsign/messageport` (`new`, `addEventListener('bsmessage')`,
|
||||
`PostBSMessage`), the `roHtmlWidgetEvent` loop, and `RebootSystem()`.
|
||||
- The notes state a widget URL may be **"an externally hosted page"** with the same access to the
|
||||
BrightSign JS APIs, which is the answer the original probe was built to get — still worth
|
||||
confirming on hardware, but the documented answer is the favourable one.
|
||||
|
||||
## Model notes
|
||||
|
||||
Target **Series 6** (ships Chromium 120) or **Series 5** (upgradeable via the `html/widget_type`
|
||||
registry key). **Series 4 is pinned to Chromium 87** — a result from one would be misleadingly
|
||||
pessimistic.
|
||||
|
||||
## Scope
|
||||
|
||||
A URL wrapper is the on-ramp, not the destination. Doing this properly on BrightSign means the
|
||||
registry for identity, SD for offline media, and their native video path rather than `<video>`.
|
||||
ScreenTinker's existing multi-zone layouts, video walls and group sync map onto that platform's
|
||||
strengths unusually well — those are the parts worth showing off.
|
||||
Target **Series 5** (Chromium 120) or newer. **Series 4 is pinned to Chromium 87**, and Series 4
|
||||
and older have fixed graphics/JS memory splits (XTx43/44: 512MB/512MB; HDx23: 256MB/128MB) where
|
||||
Series 5 allocates dynamically. Image size defaults to 2048x1280x32bpp (3840x2160 on XT/4K models)
|
||||
and is raised with `roVideoMode.SetImageSizeThreshold()`.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
111
brightsign/autozip.brs
Normal file
111
brightsign/autozip.brs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
' ScreenTinker — autorun.zip unpacker.
|
||||
'
|
||||
' Ships INSIDE autorun.zip, at its root. The card (or internal flash) carries a single file —
|
||||
' autorun.zip — and this script unpacks it in place, marks it done so it never re-extracts, and
|
||||
' reboots into the real host.
|
||||
'
|
||||
' That is the whole point of the zip: one file to hand someone, or to drop on a hundred cards,
|
||||
' instead of four files that must all arrive intact and in the right place. A partially-copied
|
||||
' set of loose files boots into something broken; a partially-copied zip simply fails to extract
|
||||
' and leaves the player where it was.
|
||||
'
|
||||
' ⚠️ autorun.brs must NOT sit next to autorun.zip on the storage root — its presence stops the zip
|
||||
' 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.
|
||||
'
|
||||
' 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+.
|
||||
|
||||
' WHERE the archive is. A player may be fed from USB, a card, an SSD, or internal flash — and the
|
||||
' unit that drove this port boots from FLASH because its card interface is physically dead. Probing
|
||||
' 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:", "SD2:", "SSD:", "FLASH:"]
|
||||
for each v in volumes
|
||||
if FileExists(v + "/autorun.zip") then return v
|
||||
end for
|
||||
return ""
|
||||
End Function
|
||||
|
||||
Sub Main()
|
||||
root$ = SourceRoot()
|
||||
if root$ = "" then
|
||||
print "[st-autozip] no autorun.zip on any volume — nothing to do"
|
||||
return
|
||||
end if
|
||||
zipPath$ = root$ + "/autorun.zip"
|
||||
extractPath$ = root$ + "/"
|
||||
donePath$ = root$ + "/autorun.zip.done"
|
||||
|
||||
print "[st-autozip] volume "; root$
|
||||
|
||||
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 FileExists(extractPath$ + "autorun.zip.done") then
|
||||
print "[st-autozip] already unpacked (autorun.zip.done present) — leaving it alone"
|
||||
return
|
||||
end if
|
||||
|
||||
print "[st-autozip] unpacking "; zipPath$
|
||||
|
||||
package = CreateObject("roBrightPackage", zipPath$)
|
||||
if package = invalid then
|
||||
print "[st-autozip] ERROR: could not open the archive — is it STORED (no compression)?"
|
||||
' Deliberately NOT marking it done: a corrupt, truncated or wrongly-compressed copy should
|
||||
' be retried once someone replaces the file, not silently skipped forever.
|
||||
return
|
||||
end if
|
||||
|
||||
' 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"
|
||||
|
||||
' 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
|
||||
|
||||
print "[st-autozip] rebooting into the unpacked player"
|
||||
sleep(2000)
|
||||
RebootSystem()
|
||||
End Sub
|
||||
|
||||
' 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
|
||||
119
brightsign/offline.html
Normal file
119
brightsign/offline.html
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>ScreenTinker — reconnecting</title>
|
||||
<style>
|
||||
/* Deliberately self-contained: this page is the thing that shows when the network is gone,
|
||||
so it can never depend on a font, a stylesheet or an image it would have to fetch. */
|
||||
html, body {
|
||||
margin: 0; height: 100%;
|
||||
background: #0d1117; color: #c9d1d9;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.card { text-align: center; max-width: 70vw; }
|
||||
h1 { font-size: 3.2vw; font-weight: 600; margin: 0 0 1.2vh; color: #e6edf3; }
|
||||
p { font-size: 1.6vw; line-height: 1.5; margin: 0.6vh 0; color: #8b949e; }
|
||||
.server { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #58a6ff; word-break: break-all; }
|
||||
.dot {
|
||||
display: inline-block; width: 0.9vw; height: 0.9vw; border-radius: 50%;
|
||||
background: #f85149; margin-right: 0.6vw; vertical-align: middle;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.25 } }
|
||||
.meta { margin-top: 3vh; font-size: 1.1vw; color: #6e7681; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1><span class="dot"></span>Can't reach the server</h1>
|
||||
<p>This display is working. It cannot currently reach</p>
|
||||
<p class="server" id="server">its ScreenTinker server</p>
|
||||
<p id="status">Retrying…</p>
|
||||
<p class="meta" id="meta"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/*
|
||||
* The local fallback page. autorun.brs loads this after three failed attempts at the real player,
|
||||
* so the screen says something truthful instead of showing white until someone visits the site.
|
||||
*
|
||||
* Two jobs, and nothing else:
|
||||
* 1. Say what is wrong, and name the server, so whoever walks past can act on it.
|
||||
* 2. Keep testing, and hand control back the moment the server answers.
|
||||
*
|
||||
* It asks the HOST to restart the widget rather than navigating itself — same reason the player
|
||||
* never calls location.reload() on this platform: an in-page navigation is not reliably a restart
|
||||
* an roHtmlWidget comes back from.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function qs(name) {
|
||||
var m = new RegExp('[?&]' + name + '=([^&]*)').exec(location.search || '');
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
var server = qs('server') || '';
|
||||
var attempt = 0;
|
||||
var startedAt = Date.now();
|
||||
|
||||
if (server) document.getElementById('server').textContent = server;
|
||||
|
||||
var port = null;
|
||||
try {
|
||||
if (typeof require === 'function') {
|
||||
var MessagePortClass = require('@brightsign/messageport');
|
||||
port = new MessagePortClass();
|
||||
}
|
||||
} catch (e) { port = null; }
|
||||
|
||||
function setStatus(text) { document.getElementById('status').textContent = text; }
|
||||
function setMeta(text) { document.getElementById('meta').textContent = text; }
|
||||
|
||||
function minutesDown() {
|
||||
var m = Math.floor((Date.now() - startedAt) / 60000);
|
||||
return m < 1 ? 'less than a minute' : (m === 1 ? '1 minute' : m + ' minutes');
|
||||
}
|
||||
|
||||
function probe() {
|
||||
attempt++;
|
||||
if (!server) { setStatus('No server configured on this card.'); return; }
|
||||
|
||||
setStatus('Retrying… (attempt ' + attempt + ')');
|
||||
|
||||
// cache-bust: a stale 200 from the widget cache would send us back to a server that is still
|
||||
// down, and the host would bounce straight back here — a loop that looks like flickering.
|
||||
var url = server.replace(/\/+$/, '') + '/api/status?probe=' + Date.now();
|
||||
|
||||
fetch(url, { cache: 'no-store' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
setStatus('Server is back — restarting the player…');
|
||||
if (port && typeof port.PostBSMessage === 'function') {
|
||||
port.PostBSMessage({ type: 'restart', reason: 'server reachable again' });
|
||||
} else {
|
||||
// No host bridge (widget without node integration). Navigating is second best, but
|
||||
// doing nothing would strand the panel here forever.
|
||||
location.href = server;
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
setMeta('Offline for ' + minutesDown() + ' · last error: ' + (err && err.message ? err.message : 'unreachable'));
|
||||
schedule();
|
||||
});
|
||||
}
|
||||
|
||||
// Backoff, capped. A panel that has been down for hours must not hammer a server that is
|
||||
// coming back up — every player on the site would hit it at once.
|
||||
function schedule() {
|
||||
var delay = attempt < 3 ? 5000 : (attempt < 10 ? 15000 : 60000);
|
||||
setTimeout(probe, delay);
|
||||
}
|
||||
|
||||
probe();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
6
brightsign/screentinker.json
Normal file
6
brightsign/screentinker.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"server_url": "https://screentinker.com",
|
||||
"sync_backend": "auto",
|
||||
"output_mode": "single",
|
||||
"inspector": true
|
||||
}
|
||||
34
brightsign/selftest/autorun.brs
Normal file
34
brightsign/selftest/autorun.brs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
' ScreenTinker — storage self-test.
|
||||
'
|
||||
' NOT the player. This is the smallest known-good BrightScript that proves the player is reading
|
||||
' the card at all, copied from the dev-cookbook html-starter example so the script itself is not
|
||||
' a variable.
|
||||
'
|
||||
' If the screen shows the green panel: storage is fine, and any failure is in the real autorun.brs
|
||||
' or downstream (network, server, widget config).
|
||||
' If the screen still says "Please insert storage device": the player is not reading this medium,
|
||||
' and no amount of work on the player code will help.
|
||||
|
||||
function main()
|
||||
mp = CreateObject("roMessagePort")
|
||||
|
||||
vidmode = CreateObject("roVideoMode")
|
||||
width = vidmode.GetResX()
|
||||
height = vidmode.GetResY()
|
||||
r = CreateObject("roRectangle", 0, 0, width, height)
|
||||
|
||||
config = {
|
||||
nodejs_enabled: true
|
||||
brightsign_js_objects_enabled: true
|
||||
url: "file:/FLASH:/index.html"
|
||||
port: mp
|
||||
}
|
||||
|
||||
h = CreateObject("roHtmlWidget", r, config)
|
||||
h.Show()
|
||||
|
||||
while true
|
||||
msg = wait(0, mp)
|
||||
print "msg received - type=";type(msg)
|
||||
end while
|
||||
end function
|
||||
33
brightsign/selftest/index.html
Normal file
33
brightsign/selftest/index.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>ScreenTinker storage self-test</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#0b3d1f;color:#eaffea;
|
||||
font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;text-align:center}
|
||||
h1{font-size:5vw;margin:0 0 2vh} p{font-size:2vw;margin:.5vh 0;color:#b9e8c4}
|
||||
code{font-size:1.6vw;color:#9fe6b5}
|
||||
</style></head>
|
||||
<body><div>
|
||||
<h1>STORAGE OK</h1>
|
||||
<p>The player is reading this card and running <code>autorun.brs</code>.</p>
|
||||
<p id="js">JavaScript: running</p>
|
||||
<p id="mods">BrightSign JS modules: checking…</p>
|
||||
<p id="net">Server reachable: checking…</p>
|
||||
</div>
|
||||
<script>
|
||||
// Each line below removes one suspect from the list, in the order they matter.
|
||||
try {
|
||||
var ok = [];
|
||||
['messageport','registry','deviceinfo','syncmanager'].forEach(function (m) {
|
||||
try { require('@brightsign/' + m); ok.push(m); } catch (e) {}
|
||||
});
|
||||
document.getElementById('mods').textContent =
|
||||
ok.length ? 'BrightSign JS modules: ' + ok.join(', ') : 'BrightSign JS modules: NONE (brightsign_js_objects_enabled?)';
|
||||
} catch (e) {
|
||||
document.getElementById('mods').textContent = 'BrightSign JS modules: require() unavailable';
|
||||
}
|
||||
|
||||
fetch('https://screentinker.com/api/status', { cache: 'no-store' })
|
||||
.then(function (r) { document.getElementById('net').textContent = 'Server reachable: YES (HTTP ' + r.status + ')'; })
|
||||
.catch(function (e) { document.getElementById('net').textContent = 'Server reachable: NO — ' + e.message; });
|
||||
</script>
|
||||
</body></html>
|
||||
1163
brightsign/st-bridge.js
Normal file
1163
brightsign/st-bridge.js
Normal file
File diff suppressed because it is too large
Load diff
169
brightsign/st-sync.js
Normal file
169
brightsign/st-sync.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* ScreenTinker — BrightSign native synchronisation (SyncManager).
|
||||
*
|
||||
* The alternative to our own clock-derived group sync, for groups where every member is a
|
||||
* BrightSign. Frame-accurate, because it is the player's own video pipeline doing the aligning:
|
||||
* setSyncParams() is a BrightSign extension on the standard <video> element, and once it is set
|
||||
* the element keeps itself in step without further help.
|
||||
*
|
||||
* Shape of the protocol (docs.brightsign.biz/developers/syncmanager, and the dev-cookbook
|
||||
* examples/browser/syncmanager-js example):
|
||||
*
|
||||
* - One member is the LEADER. It calls synchronize(id, msDelay), which multicasts a timestamped
|
||||
* event. Everyone else listens. Ours is leaderless; this one is not, so the group needs a
|
||||
* designated leader and goes unsynchronised if that member is off.
|
||||
* - The leader receives its OWN broadcast and starts from it too. That is what stops it running
|
||||
* ahead of the followers by the width of the network.
|
||||
* - synchronize() REPEATS AT 1Hz so a player powered on late still joins the session. Acting on
|
||||
* every repeat would reload the video once a second forever, which on screen looks like a
|
||||
* stutter or a restart loop rather than a sync fault. Dedupe on the id — mandatory.
|
||||
* - Multicast, so every member must share one L2 network. A group spanning sites or VLANs
|
||||
* cannot use this at all.
|
||||
*
|
||||
* Requires BrightSignOS 8.2.10+, and networking/ptp_domain = "0" (autorun.brs applies that, with
|
||||
* the one reboot it needs).
|
||||
*
|
||||
* Video only: images and widgets have no setSyncParams, so they get item-boundary alignment from
|
||||
* the sync event and nothing finer. That is a real functional difference from our own protocol,
|
||||
* not just an accuracy one.
|
||||
*
|
||||
* Safe to load anywhere — with no SyncManager module every method is a no-op and available()
|
||||
* reports false, so the player falls back to its own sync.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// The cookbook's defaults. Kept as defaults rather than constants so a site with its own
|
||||
// multicast policy can be pointed elsewhere without a code change.
|
||||
var DEFAULTS = {
|
||||
networkInterface: '', // '' = let the OS choose
|
||||
domain: 'ScreenTinkerSync',
|
||||
multicastAddress: '224.0.126.10',
|
||||
multicastPort: 1539,
|
||||
prepareMs: 1000 // lead time so every member can load before playback starts
|
||||
};
|
||||
|
||||
function tryRequire(name) {
|
||||
try {
|
||||
if (typeof require !== 'function') return null;
|
||||
return require(name);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
var SyncManagerClass = tryRequire('@brightsign/syncmanager');
|
||||
|
||||
function Sync(options) {
|
||||
var opts = options || {};
|
||||
this.config = {
|
||||
networkInterface: opts.networkInterface !== undefined ? opts.networkInterface : DEFAULTS.networkInterface,
|
||||
domain: opts.domain || DEFAULTS.domain,
|
||||
multicastAddress: opts.multicastAddress || DEFAULTS.multicastAddress,
|
||||
multicastPort: opts.multicastPort || DEFAULTS.multicastPort,
|
||||
prepareMs: opts.prepareMs !== undefined ? opts.prepareMs : DEFAULTS.prepareMs
|
||||
};
|
||||
this.sm = null;
|
||||
this.isLeader = false;
|
||||
this.lastId = null; // the dedupe that makes the 1Hz repeat harmless
|
||||
this.onItem = null; // (syncEvent) => void, fired once per NEW id
|
||||
this.lastEvent = null;
|
||||
}
|
||||
|
||||
Sync.prototype.available = function () { return !!SyncManagerClass; };
|
||||
|
||||
/*
|
||||
* Join the sync session. `leader` designates this member as the one that broadcasts.
|
||||
* Returns false when the module is absent, so the caller can fall back rather than assume.
|
||||
*/
|
||||
Sync.prototype.start = function (leader) {
|
||||
if (!SyncManagerClass) return false;
|
||||
if (this.sm) this.stop();
|
||||
|
||||
try {
|
||||
this.sm = new SyncManagerClass(
|
||||
this.config.networkInterface,
|
||||
this.config.domain,
|
||||
this.config.multicastAddress,
|
||||
this.config.multicastPort
|
||||
);
|
||||
} catch (e) {
|
||||
this.sm = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isLeader = !!leader;
|
||||
try {
|
||||
// A follower must NOT set this. Assigning false is harmless per the API, but the examples
|
||||
// simply omit it on followers, so match that.
|
||||
if (this.isLeader) this.sm.leader = true;
|
||||
this.sm.encrypted = false;
|
||||
} catch (e) { /* an older build may not expose every property */ }
|
||||
|
||||
var self = this;
|
||||
try {
|
||||
this.sm.addEventListener('syncevent', function (e) { self._onEvent(e); });
|
||||
} catch (e) {
|
||||
this.stop();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/* Internal. Both roles land here — including the leader, for its own broadcast. */
|
||||
Sync.prototype._onEvent = function (e) {
|
||||
if (!e || e.id === undefined || e.id === null) return;
|
||||
// THE trap: synchronize() repeats at 1Hz so late players can join. Only the first occurrence
|
||||
// of an id is a new session; every repeat after it must be ignored or the video reloads
|
||||
// once a second, forever.
|
||||
if (e.id === this.lastId) return;
|
||||
this.lastId = e.id;
|
||||
this.lastEvent = e;
|
||||
if (typeof this.onItem === 'function') {
|
||||
try { this.onItem(e); } catch (err) { /* a bad handler must not kill the session */ }
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Bind a video element to the current sync session. Call from the onItem handler.
|
||||
* After this the element keeps itself aligned; nothing further is required per frame.
|
||||
*/
|
||||
Sync.prototype.attachVideo = function (video, event) {
|
||||
var ev = event || this.lastEvent;
|
||||
if (!video || !ev) return false;
|
||||
if (typeof video.setSyncParams !== 'function') return false; // not a BrightSign <video>
|
||||
try {
|
||||
video.setSyncParams(ev.domain, ev.id, ev.iso_timestamp);
|
||||
video.load();
|
||||
var p = video.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(function () { /* autoplay guard */ });
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
};
|
||||
|
||||
/*
|
||||
* LEADER ONLY: open a new sync session for a playlist item. `itemKey` should identify the item
|
||||
* so the id changes on every advance — a repeated id would be swallowed by the dedupe above and
|
||||
* the group would sit on the previous item.
|
||||
*/
|
||||
Sync.prototype.announce = function (itemKey, nowMs) {
|
||||
if (!this.sm || !this.isLeader) return false;
|
||||
var id = 'st_' + String(itemKey) + '_' + String(nowMs || Date.now());
|
||||
try {
|
||||
this.sm.synchronize(id, this.config.prepareMs);
|
||||
return id;
|
||||
} catch (e) { return false; }
|
||||
};
|
||||
|
||||
Sync.prototype.stop = function () {
|
||||
if (!this.sm) return;
|
||||
try { if (typeof this.sm.close === 'function') this.sm.close(); } catch (e) { /* ignore */ }
|
||||
this.sm = null;
|
||||
this.lastId = null;
|
||||
this.lastEvent = null;
|
||||
};
|
||||
|
||||
global.ScreenTinkerBSSync = {
|
||||
create: function (options) { return new Sync(options); },
|
||||
available: function () { return !!SyncManagerClass; },
|
||||
DEFAULTS: DEFAULTS
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
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.0
|
||||
version: 1.9.36
|
||||
description: |
|
||||
Public, token-scoped REST API for ScreenTinker digital signage.
|
||||
|
||||
|
|
@ -109,6 +109,70 @@ components:
|
|||
created_at:
|
||||
type: integer
|
||||
|
||||
# --- Network addresses -------------------------------------------------------------
|
||||
# Two different addresses, easy to confuse, so both are spelled out. They answer
|
||||
# different questions and either can be null.
|
||||
ip_address:
|
||||
type: [string, "null"]
|
||||
description: |
|
||||
The device's **public (WAN)** address as observed by the server when the player
|
||||
connected — i.e. what the internet sees. Behind a reverse proxy this is taken from
|
||||
the first `X-Forwarded-For` entry, otherwise the socket peer address. Every device
|
||||
on one site normally shares this. Null until the device has connected at least once.
|
||||
local_ip:
|
||||
type: [string, "null"]
|
||||
description: |
|
||||
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, 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
|
||||
# never reported, and for platforms that cannot supply a given metric — treat every
|
||||
# field here as optional rather than assuming a web player reports what Android does.
|
||||
wifi_ssid:
|
||||
type: [string, "null"]
|
||||
description: |
|
||||
Wi-Fi network name, or null on a wired/unknown connection.
|
||||
|
||||
Special value `"permission"` means the device is on Wi-Fi but the operating system
|
||||
withheld the name — on Android 10+ reading the SSID requires a location permission
|
||||
that ScreenTinker only asks for if an operator opts in. Treat `"permission"` as
|
||||
"connected, name unavailable", not as a network literally called that.
|
||||
wifi_rssi:
|
||||
type: [integer, "null"]
|
||||
description: Signal strength in dBm (negative; closer to zero is stronger).
|
||||
battery_level:
|
||||
type: [integer, "null"]
|
||||
description: Battery percentage 0-100, or null on mains-powered hardware.
|
||||
battery_charging:
|
||||
type: [integer, "null"]
|
||||
description: 1 charging, 0 not charging, null unknown.
|
||||
storage_free_mb:
|
||||
type: [integer, "null"]
|
||||
storage_total_mb:
|
||||
type: [integer, "null"]
|
||||
ram_free_mb:
|
||||
type: [integer, "null"]
|
||||
ram_total_mb:
|
||||
type: [integer, "null"]
|
||||
cpu_usage:
|
||||
type: [number, "null"]
|
||||
description: Recent CPU utilisation as a percentage, where the platform exposes it.
|
||||
uptime_seconds:
|
||||
type: [integer, "null"]
|
||||
description: Seconds since the device booted.
|
||||
|
||||
Playlist:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -619,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:
|
||||
|
|
@ -761,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. }
|
||||
|
|
@ -1188,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:
|
||||
|
|
@ -1506,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.
|
||||
349
docs/player-parity.md
Normal file
349
docs/player-parity.md
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
# Player parity matrix
|
||||
|
||||
What each player can actually do, verified against the code rather than assumed. This is the
|
||||
document that says where the remaining work is, so a wrong "yes" here is worse than a missing row:
|
||||
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** — ✅ 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. 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 (`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` | ✅ `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` | ✅ `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` — 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` | ⚠️ `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` | ✅ `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. Tizen and BrightSign both
|
||||
decline these explicitly and in writing in their own capability modules.
|
||||
|
||||
| capability | Android | Web / Tizen / BrightSign |
|
||||
|---|---|---|
|
||||
| `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` | ✅ `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
|
||||
|
||||
Prioritised by how visible the failure is to an operator.
|
||||
|
||||
**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.** 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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Baselines: what an un-updated display is assumed to be able to 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">
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const api = {
|
|||
// and the re-adopt action that applies a snapshot onto a newly-paired device.
|
||||
getRemovedDevices: () => request('/devices/removed'),
|
||||
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
|
||||
setDevicePin: (id, body) => request(`/devices/${id}/settings-pin`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
|
||||
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
|
||||
|
|
@ -193,6 +194,7 @@ export const api = {
|
|||
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
|
||||
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
|
||||
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
||||
clearDevicePlaylist: (device_id) => request(`/devices/${device_id}/playlist`, { method: 'DELETE' }),
|
||||
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
||||
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
||||
|
||||
|
|
@ -208,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 }) }),
|
||||
|
|
@ -221,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`),
|
||||
|
|
@ -242,6 +251,8 @@ export const api = {
|
|||
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
adminListOrgs: () => request('/admin/orgs'),
|
||||
// Platform-admin view: EVERY plan incl. hidden ones, with subscriber counts.
|
||||
adminListPlans: () => request('/admin/plans'),
|
||||
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
|
||||
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
|
||||
aiGetSettings: () => request('/ai/settings'),
|
||||
|
|
@ -255,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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ export function computeSteps({ devices = [], content = [], playlists = [] } = {}
|
|||
const hasPlaylist = playlists.length > 0;
|
||||
// "On screen" is the only step that cannot be faked by creating an object and walking away:
|
||||
// some screen has to actually be pointed at something.
|
||||
const isAssigned = devices.some((d) => d.playlist_id || d.default_content_id || d.layout_id);
|
||||
// default_content_id is deliberately NOT counted. No player reads it — grep the whole tree and
|
||||
// it appears only in this checklist, the device route, the settings snapshot and the schema —
|
||||
// so counting it ticked "content assigned" for a screen that goes on showing "waiting for
|
||||
// content". A checklist that lies about the one thing it is there to confirm is worse than no
|
||||
// checklist. The field itself is left alone; that is a separate decision.
|
||||
const isAssigned = devices.some((d) => d.playlist_id || d.layout_id);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export default {
|
|||
'dashboard.prompt_group_name': 'Gruppenname:',
|
||||
'dashboard.error_pairing_code': 'Geben Sie einen gültigen 6-stelligen Kopplungscode ein',
|
||||
'dashboard.confirm_add_to_group': '{name} ist bereits in: {groups}\n\nAuch zu „{target}“ hinzufügen?',
|
||||
'dashboard.confirm_move_to_group': '{name} ist derzeit in: {groups}\n\nNach "{target}" verschieben?',
|
||||
'dashboard.toast.move_partial': 'Entfernen aus {group} fehlgeschlagen — Bildschirm ist noch in beiden',
|
||||
'dashboard.confirm_assign_playlist': 'Playlist „{playlist}“ allen Geräten in „{group}“ zuweisen?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} alle {n} Geräte in „{group}“?\n\nDies kann nicht rückgängig gemacht werden.',
|
||||
'dashboard.confirm_delete_group': 'Diese Gruppe löschen? Geräte sind nicht betroffen.',
|
||||
|
|
@ -257,6 +259,9 @@ export default {
|
|||
'device.playlist_picker.with_auto': '{name} (auto) — {n} Elemente',
|
||||
'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',
|
||||
'device.info.size_free': '{size} frei',
|
||||
|
|
@ -343,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',
|
||||
|
|
@ -759,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',
|
||||
|
|
@ -865,6 +877,10 @@ export default {
|
|||
'admin.access_denied_desc': 'Plattform-Admin-Zugriff erforderlich.',
|
||||
'admin.all_users': 'Alle Benutzer',
|
||||
'admin.plans': 'Abonnementpläne',
|
||||
'admin.col.accounts': 'Konten',
|
||||
'admin.col.screens': 'Bildschirme',
|
||||
'admin.plan_hidden': 'ausgeblendet',
|
||||
'admin.plan_orphaned': 'Konten mit einem nicht mehr vorhandenen Tarif',
|
||||
'admin.system': 'System',
|
||||
'admin.col.user': 'Benutzer',
|
||||
'admin.col.auth': 'Auth',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -207,6 +296,12 @@ export default {
|
|||
'dashboard.group_sync.toast_on': 'Synchronized playback enabled',
|
||||
'dashboard.group_sync.toast_off': 'Synchronized playback disabled',
|
||||
'dashboard.group_sync.toast_resync': 'Resync sent to group',
|
||||
'dashboard.group_sync.backend_auto': 'Sync: Auto',
|
||||
'dashboard.group_sync.backend_screentinker': 'Sync: Standard',
|
||||
'dashboard.group_sync.backend_brightsign': 'Sync: BrightSign',
|
||||
'dashboard.group_sync.backend_hint': "Which synchronisation protocol this group uses. Standard works across every player type and keeps displays aligned to the second, with no leader and no internet needed. BrightSign is frame-accurate but only works when every display in the group is a BrightSign on the same network, and it synchronises video only. Auto picks BrightSign when the group can actually run it, and Standard otherwise.",
|
||||
'dashboard.group_sync.toast_backend': 'Sync protocol updated',
|
||||
'dashboard.group_sync.toast_downgraded': 'Saved, but this group cannot run that protocol:',
|
||||
'dashboard.manage_tooltip': 'Add/remove devices',
|
||||
'dashboard.delete_group_tooltip': 'Delete group',
|
||||
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',
|
||||
|
|
@ -223,6 +318,8 @@ export default {
|
|||
'dashboard.prompt_group_name': 'Group name:',
|
||||
'dashboard.error_pairing_code': 'Enter a valid 6-digit pairing code',
|
||||
'dashboard.confirm_add_to_group': '{name} is already in: {groups}\n\nAdd it to "{target}" too?',
|
||||
'dashboard.confirm_move_to_group': '{name} is currently in: {groups}\n\nMove it to "{target}"?',
|
||||
'dashboard.toast.move_partial': 'Could not remove it from {group} — it is still in both',
|
||||
'dashboard.confirm_assign_playlist': 'Assign playlist "{playlist}" to all devices in "{group}"?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} all {n} devices in "{group}"?\n\nThis cannot be undone.',
|
||||
'dashboard.confirm_delete_group': 'Delete this group? Devices will not be affected.',
|
||||
|
|
@ -238,6 +335,7 @@ export default {
|
|||
'dashboard.toast.playlist_assigned_other': 'Playlist assigned to {n} devices',
|
||||
'dashboard.toast.command_sent': '{cmd} sent to {sent}/{total} devices',
|
||||
'dashboard.toast.command_sent_with_offline': '{cmd} sent to {sent}/{total} devices ({offline} offline)',
|
||||
'dashboard.toast.command_unsupported_n': '{n} skipped — their players do not support it.',
|
||||
|
||||
// Content library
|
||||
'content.title': 'Content Library',
|
||||
|
|
@ -394,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',
|
||||
|
|
@ -463,15 +564,36 @@ export default {
|
|||
// Info cards
|
||||
'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',
|
||||
'device.info.size_free': '{size} free',
|
||||
// "Player storage" rather than "Storage": on a browser-family player this is the widget's cache
|
||||
// quota, not the device filesystem, and it sits in the same column as Android's real disk usage.
|
||||
'device.info.player_storage': 'Player Storage',
|
||||
'device.info.hardware_model': 'Model',
|
||||
'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',
|
||||
'device.info.brightsign_player': 'BrightSign',
|
||||
'device.info.wifi': 'WiFi',
|
||||
'device.info.uptime': 'Uptime',
|
||||
'device.info.android_version': 'Android Version',
|
||||
'device.info.app_version': 'App Version',
|
||||
'device.pin.rotate': 'Rotate',
|
||||
'device.pin.set': 'Set…',
|
||||
'device.pin.rotate_confirm': 'Generate a new settings PIN for this display? The current PIN stops working immediately.',
|
||||
'device.pin.set_prompt': 'New 6-digit settings PIN',
|
||||
'device.pin.updated_live': 'PIN updated — the display has it now',
|
||||
'device.pin.updated_offline': 'PIN saved — the display will pick it up when it reconnects',
|
||||
'device.pin.failed': 'Could not update the PIN',
|
||||
'device.info.settings_pin': 'Settings PIN',
|
||||
'device.info.settings_pin_hint': 'On-device settings menu (2× Back)',
|
||||
'device.info.screen_resolution': 'Screen Resolution',
|
||||
|
|
@ -518,8 +640,19 @@ 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.',
|
||||
'device.ota.hint': 'When off, this device is never offered an update — an MDM or operator owns its updates instead. Turn OFF for MDM-managed panels (e.g. Pivot/MAXHUB) so the app never shows a self-install dialog.',
|
||||
'device.reboot_schedule.label': 'Nightly reboot',
|
||||
'device.reboot_schedule.hint': 'Reboot this panel once a day at this device-local time (leave blank for off). A clean nightly reboot clears memory leaks and re-syncs the clock. Silent on device-owner panels; a no-op on panels that can\'t self-reboot.',
|
||||
|
|
@ -550,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',
|
||||
|
|
@ -616,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',
|
||||
|
|
@ -637,10 +775,16 @@ 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',
|
||||
'device.toast.command_no_ack': '{cmd} — no server response',
|
||||
'device.toast.command_unsupported': '{cmd} — this player does not support it ({cap}). Reload the page to refresh the controls.',
|
||||
'device.caps.title': 'Player capabilities',
|
||||
'device.caps.declared': 'Reported by the player itself. Controls this display cannot honour are hidden.',
|
||||
'device.caps.assumed': 'This player has not reported its capabilities, so the defaults for its platform are assumed. They update the next time it connects.',
|
||||
'device.caps.none': 'The player reports it can do nothing.',
|
||||
|
||||
// Settings
|
||||
'settings.title': 'Settings',
|
||||
|
|
@ -697,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',
|
||||
|
|
@ -1121,6 +1292,14 @@ export default {
|
|||
'playlist.click_to_edit_desc': 'Click to edit description',
|
||||
'playlist.add_content': '+ Add Content',
|
||||
'playlist.delete_playlist': 'Delete Playlist',
|
||||
'playlist.layout_fullscreen': 'Fullscreen — all content shares the whole screen',
|
||||
'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.',
|
||||
|
|
@ -1268,8 +1447,22 @@ 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',
|
||||
'admin.col.screens': 'Screens',
|
||||
'admin.plan_hidden': 'hidden',
|
||||
'admin.plan_orphaned': 'Accounts on a plan that no longer exists',
|
||||
'admin.system': 'System',
|
||||
// #15: instance-level default branding
|
||||
'admin.branding.title': 'Default branding',
|
||||
|
|
@ -1489,6 +1682,7 @@ export default {
|
|||
'layout.properties': 'Properties',
|
||||
'layout.delete_zone': 'Delete Zone',
|
||||
'layout.zone_n': 'Zone {n}',
|
||||
'layout.rename': 'Layout name — click to rename',
|
||||
'layout.prop.name': 'Name',
|
||||
'layout.prop.x': 'X (%)',
|
||||
'layout.prop.y': 'Y (%)',
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export default {
|
|||
'dashboard.prompt_group_name': 'Nombre del grupo:',
|
||||
'dashboard.error_pairing_code': 'Ingresa un código de vinculación válido de 6 dígitos',
|
||||
'dashboard.confirm_add_to_group': '{name} ya está en: {groups}\n\n¿Agregarlo también a "{target}"?',
|
||||
'dashboard.confirm_move_to_group': '{name} está actualmente en: {groups}\n\n¿Moverla a "{target}"?',
|
||||
'dashboard.toast.move_partial': 'No se pudo quitar de {group}: sigue en ambos',
|
||||
'dashboard.confirm_assign_playlist': '¿Asignar la lista "{playlist}" a todos los dispositivos de "{group}"?',
|
||||
'dashboard.confirm_destructive_command': '¿{cmd} todos los {n} dispositivos de "{group}"?\n\nEsto no se puede deshacer.',
|
||||
'dashboard.confirm_delete_group': '¿Eliminar este grupo? Los dispositivos no se verán afectados.',
|
||||
|
|
@ -287,6 +289,9 @@ export default {
|
|||
'device.playlist_picker.with_auto': '{name} (auto) — {n} elementos',
|
||||
'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',
|
||||
'device.info.size_free': '{size} libres',
|
||||
|
|
@ -373,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',
|
||||
|
|
@ -789,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',
|
||||
|
|
@ -895,6 +907,10 @@ export default {
|
|||
'admin.access_denied_desc': 'Se requiere acceso de administrador de plataforma.',
|
||||
'admin.all_users': 'Todos los usuarios',
|
||||
'admin.plans': 'Planes de suscripción',
|
||||
'admin.col.accounts': 'Cuentas',
|
||||
'admin.col.screens': 'Pantallas',
|
||||
'admin.plan_hidden': 'oculto',
|
||||
'admin.plan_orphaned': 'Cuentas en un plan que ya no existe',
|
||||
'admin.system': 'Sistema',
|
||||
'admin.col.user': 'Usuario',
|
||||
'admin.col.auth': 'Auth',
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export default {
|
|||
'dashboard.prompt_group_name': 'Nom du groupe :',
|
||||
'dashboard.error_pairing_code': 'Saisissez un code d\'appairage valide à 6 chiffres',
|
||||
'dashboard.confirm_add_to_group': '{name} est déjà dans : {groups}\n\nL\'ajouter aussi à « {target} » ?',
|
||||
'dashboard.confirm_move_to_group': '{name} est actuellement dans : {groups}\n\nLe déplacer vers "{target}" ?',
|
||||
'dashboard.toast.move_partial': 'Impossible de le retirer de {group} — il est encore dans les deux',
|
||||
'dashboard.confirm_assign_playlist': 'Attribuer la liste « {playlist} » à tous les appareils de « {group} » ?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} les {n} appareils de « {group} » ?\n\nCette action est irréversible.',
|
||||
'dashboard.confirm_delete_group': 'Supprimer ce groupe ? Les appareils ne seront pas affectés.',
|
||||
|
|
@ -257,6 +259,9 @@ export default {
|
|||
'device.playlist_picker.with_auto': '{name} (auto) — {n} éléments',
|
||||
'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',
|
||||
'device.info.size_free': '{size} libres',
|
||||
|
|
@ -343,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é',
|
||||
|
|
@ -759,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',
|
||||
|
|
@ -865,6 +877,10 @@ export default {
|
|||
'admin.access_denied_desc': 'Accès administrateur plateforme requis.',
|
||||
'admin.all_users': 'Tous les utilisateurs',
|
||||
'admin.plans': 'Plans d\'abonnement',
|
||||
'admin.col.accounts': 'Comptes',
|
||||
'admin.col.screens': 'Écrans',
|
||||
'admin.plan_hidden': 'masqué',
|
||||
'admin.plan_orphaned': "Comptes sur un forfait qui n'existe plus",
|
||||
'admin.system': 'Système',
|
||||
'admin.col.user': 'Utilisateur',
|
||||
'admin.col.auth': 'Auth',
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ export default {
|
|||
'dashboard.prompt_group_name': 'Nome gruppo:',
|
||||
'dashboard.error_pairing_code': 'Inserisci un codice di associazione valido di 6 cifre',
|
||||
'dashboard.confirm_add_to_group': '{name} è già presente in: {groups}\n\nAggiungerlo anche a "{target}"?',
|
||||
'dashboard.confirm_move_to_group': '{name} è attualmente in: {groups}\n\nSpostarlo in "{target}"?',
|
||||
'dashboard.toast.move_partial': 'Impossibile rimuoverlo da {group}: è ancora in entrambi',
|
||||
'dashboard.confirm_assign_playlist': 'Assegnare la playlist "{playlist}" a tutti i dispositivi in "{group}"?',
|
||||
'dashboard.confirm_destructive_command': 'Eseguire {cmd} su tutti i {n} dispositivi in "{group}"?\n\nL\'azione è irreversibile.',
|
||||
'dashboard.confirm_delete_group': 'Eliminare questo gruppo? I dispositivi non verranno rimossi dal sistema.',
|
||||
|
|
@ -273,6 +275,9 @@ export default {
|
|||
// Info cards
|
||||
'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',
|
||||
'device.info.size_free': '{size} liberi',
|
||||
|
|
@ -367,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',
|
||||
|
|
@ -747,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',
|
||||
|
|
@ -823,6 +835,10 @@ export default {
|
|||
'admin.access_denied_desc': 'È richiesto l\'accesso come amministratore di piattaforma.',
|
||||
'admin.all_users': 'Tutti gli Utenti',
|
||||
'admin.plans': 'Piani di Abbonamento',
|
||||
'admin.col.accounts': 'Account',
|
||||
'admin.col.screens': 'Schermi',
|
||||
'admin.plan_hidden': 'nascosto',
|
||||
'admin.plan_orphaned': 'Account su un piano che non esiste più',
|
||||
'admin.system': 'Sistema',
|
||||
'admin.col.user': 'Utente',
|
||||
'admin.col.auth': 'Autenticazione',
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export default {
|
|||
'dashboard.prompt_group_name': 'Nome do grupo:',
|
||||
'dashboard.error_pairing_code': 'Digite um código de pareamento válido de 6 dígitos',
|
||||
'dashboard.confirm_add_to_group': '{name} já está em: {groups}\n\nAdicionar também a "{target}"?',
|
||||
'dashboard.confirm_move_to_group': '{name} está atualmente em: {groups}\n\nMover para "{target}"?',
|
||||
'dashboard.toast.move_partial': 'Não foi possível remover de {group} — continua em ambos',
|
||||
'dashboard.confirm_assign_playlist': 'Atribuir a playlist "{playlist}" a todos os dispositivos de "{group}"?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} todos os {n} dispositivos de "{group}"?\n\nIsso não pode ser desfeito.',
|
||||
'dashboard.confirm_delete_group': 'Excluir este grupo? Os dispositivos não serão afetados.',
|
||||
|
|
@ -257,6 +259,9 @@ export default {
|
|||
'device.playlist_picker.with_auto': '{name} (auto) — {n} itens',
|
||||
'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',
|
||||
'device.info.size_free': '{size} livres',
|
||||
|
|
@ -343,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',
|
||||
|
|
@ -759,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',
|
||||
|
|
@ -865,6 +877,10 @@ export default {
|
|||
'admin.access_denied_desc': 'Acesso de admin da plataforma necessário.',
|
||||
'admin.all_users': 'Todos os usuários',
|
||||
'admin.plans': 'Planos de assinatura',
|
||||
'admin.col.accounts': 'Contas',
|
||||
'admin.col.screens': 'Ecrãs',
|
||||
'admin.plan_hidden': 'oculto',
|
||||
'admin.plan_orphaned': 'Contas num plano que já não existe',
|
||||
'admin.system': 'Sistema',
|
||||
'admin.col.user': 'Usuário',
|
||||
'admin.col.auth': 'Auth',
|
||||
|
|
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { esc } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = `
|
||||
|
|
|
|||
|
|
@ -12,7 +12,20 @@ import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
|
|||
import { mapMutationError } from './workspace-members.js';
|
||||
|
||||
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
// #14: the platform user-management dropdown manages users.role (the
|
||||
// PLATFORM-level role) only - workspace/org roles are managed in the members
|
||||
|
|
@ -66,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>
|
||||
|
|
@ -124,6 +146,7 @@ export async function render(container) {
|
|||
|
||||
loadUsers();
|
||||
loadOrgs();
|
||||
loadSsoOnlyRequests();
|
||||
loadBranding();
|
||||
loadPlans();
|
||||
loadSystem();
|
||||
|
|
@ -133,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;
|
||||
|
|
@ -263,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>
|
||||
|
|
@ -373,7 +466,10 @@ async function loadStatusDebug() {
|
|||
async function loadPlans() {
|
||||
const el = document.getElementById('plansTable');
|
||||
try {
|
||||
const plans = await fetch('/api/subscription/plans').then(r => r.json());
|
||||
// Admin endpoint, not /api/subscription/plans: that one filters `active = 1` because it feeds
|
||||
// the pricing page, so a deliberately hidden plan (a comped or beta tier) was invisible to the
|
||||
// operator too. Here we want every plan, plus who is actually on each one.
|
||||
const { plans, orphaned } = await api.adminListPlans();
|
||||
el.innerHTML = `
|
||||
<div class="table-wrap">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:500px">
|
||||
|
|
@ -383,20 +479,31 @@ async function loadPlans() {
|
|||
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.storage')}</th>
|
||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.monthly')}</th>
|
||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.yearly')}</th>
|
||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.accounts')}</th>
|
||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.screens')}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${plans.map(p => `
|
||||
<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:8px;font-weight:500">${p.display_name}</td>
|
||||
<tr style="border-bottom:1px solid var(--border)${p.active ? '' : ';opacity:.7'}">
|
||||
<td style="padding:8px;font-weight:500">${esc(p.display_name)}
|
||||
<span style="color:var(--text-muted);font-weight:400;font-size:11px">${esc(p.id)}</span>
|
||||
${p.active ? '' : `<span style="margin-left:6px;font-size:10px;padding:1px 6px;border:1px solid var(--border);border-radius:8px;color:var(--text-muted)">${t('admin.plan_hidden')}</span>`}
|
||||
</td>
|
||||
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
|
||||
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
|
||||
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</td>
|
||||
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
|
||||
<td style="padding:8px;text-align:right${p.user_count ? ';font-weight:500' : ';color:var(--text-muted)'}">${p.user_count}</td>
|
||||
<td style="padding:8px;text-align:right;color:var(--text-muted)">${p.device_count}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${(orphaned && orphaned.length) ? `
|
||||
<p style="margin-top:10px;color:var(--danger);font-size:12px">
|
||||
${t('admin.plan_orphaned')}: ${orphaned.map(o => `<strong>${esc(o.plan_id)}</strong> (${o.user_count})`).join(', ')}
|
||||
</p>` : ''}
|
||||
`;
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -714,6 +714,15 @@ function showEditModal(contentItem, onSave) {
|
|||
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option>
|
||||
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option>
|
||||
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option>
|
||||
${['video/mp4','video/webm','image/jpeg','image/png','image/gif','image/webp'].includes(contentItem.mime_type) ? '' : `
|
||||
<!-- The item's ACTUAL type, for the cases the six choices above cannot express:
|
||||
video/youtube, and uploads the sniffer accepts but this list omits (.mov, .svg,
|
||||
.heic, .avif, .bmp). Without it no option matched, the browser selected the first
|
||||
one - video/mp4 - and pressing Save with nothing else changed rewrote the item's
|
||||
type. mime_type is the renderer selector in every player, so a YouTube item became
|
||||
an "MP4" whose source is an embed page: a dead slide on every screen, and
|
||||
unrecoverable here because there was no option to set it back. -->
|
||||
<option value="${esc(contentItem.mime_type || '')}" selected>${esc(contentItem.mime_type || '')}</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -234,6 +269,14 @@ function renderGroupSection(group, devices, playlists) {
|
|||
<input type="checkbox" class="group-sync-cb" data-group-id="${group.id}" ${group.sync_enabled ? 'checked' : ''}> ${t('dashboard.group_sync.label')}
|
||||
</label>
|
||||
${group.sync_enabled ? `
|
||||
<select class="input group-backend-select" data-group-id="${group.id}" style="width:130px;padding:4px 8px;font-size:12px;background:var(--bg-input)" title="${esc(t('dashboard.group_sync.backend_hint'))}">
|
||||
<option value="auto" ${(group.sync_backend || 'auto') === 'auto' ? 'selected' : ''}>${t('dashboard.group_sync.backend_auto')}</option>
|
||||
<option value="screentinker" ${group.sync_backend === 'screentinker' ? 'selected' : ''}>${t('dashboard.group_sync.backend_screentinker')}</option>
|
||||
<option value="brightsign" ${group.sync_backend === 'brightsign' ? 'selected' : ''}>${t('dashboard.group_sync.backend_brightsign')}</option>
|
||||
</select>
|
||||
${group.sync_effective ? `
|
||||
<span style="font-size:11px;color:${group.sync_downgraded ? 'var(--warning, #d97706)' : 'var(--text-muted)'};white-space:nowrap"
|
||||
title="${esc(group.sync_reason || '')}">${group.sync_downgraded ? '⚠ ' : ''}${esc(group.sync_effective)}${group.sync_reason ? ' — ' + esc(group.sync_reason) : ''}</span>` : ''}
|
||||
<button class="btn group-resync-btn" data-group-id="${group.id}" style="padding:4px 10px;font-size:12px" title="${esc(t('dashboard.group_sync.resync_hint'))}">${t('dashboard.group_sync.resync')}</button>` : ''}
|
||||
` : ''}
|
||||
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
|
||||
|
|
@ -247,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">
|
||||
|
|
@ -389,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
|
||||
|
|
@ -397,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) => {
|
||||
|
|
@ -409,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
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -438,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() {
|
||||
|
|
@ -651,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
|
||||
|
|
@ -767,13 +861,28 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info');
|
||||
return;
|
||||
}
|
||||
// If the device is in another group, mirror the Manage modal's confirm.
|
||||
const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name);
|
||||
// Dragging a screen onto a group MOVES it. This used to borrow the Manage modal's
|
||||
// "add it too?" confirm and then only add — so the screen ended up in both groups while the
|
||||
// toast claimed it had moved, the page still showed the old group, and a second attempt said
|
||||
// "already in group 2". Reported by a customer doing exactly that with two screens.
|
||||
// The Manage modal keeps add/remove checkboxes: multi-group membership is deliberate THERE.
|
||||
// It is not deliberate here, and it is not harmless — deviceSyncGroup() picks arbitrarily
|
||||
// when a device is in several sync-enabled groups, so a half-move leaves sync ambiguous.
|
||||
const others = groupsByDeviceId.get(deviceId) || [];
|
||||
if (others.length > 0) {
|
||||
if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return;
|
||||
if (!confirm(t('dashboard.confirm_move_to_group', {
|
||||
name: deviceName, groups: others.map(g => g.name).join(', '), target: targetGroup.name,
|
||||
}))) return;
|
||||
}
|
||||
try {
|
||||
// Add first, then drop the old memberships: if the add fails the screen keeps the group it
|
||||
// had rather than being left ungrouped by a half-finished move.
|
||||
await api.addDeviceToGroup(groupId, deviceId);
|
||||
for (const g of others) {
|
||||
if (g.id === groupId) continue;
|
||||
try { await api.removeDeviceFromGroup(g.id, deviceId); }
|
||||
catch (e) { showToast(t('dashboard.toast.move_partial', { group: g.name }), 'warning'); }
|
||||
}
|
||||
showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success');
|
||||
loadDashboard();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
|
|
@ -847,6 +956,31 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
});
|
||||
});
|
||||
|
||||
// Choose the sync protocol. The server may refuse the choice (native sync needs every member to
|
||||
// be a BrightSign on one L2 network), so re-render from its answer rather than assuming the
|
||||
// request took — showing a setting that isn't in force is exactly what makes a drifting wall
|
||||
// impossible to diagnose.
|
||||
document.querySelectorAll('.group-backend-select').forEach(sel => {
|
||||
sel.addEventListener('change', async (e) => {
|
||||
const groupId = e.target.dataset.groupId;
|
||||
const previous = sel.dataset.previous || 'auto';
|
||||
const chosen = e.target.value;
|
||||
try {
|
||||
const updated = await api.updateGroup(groupId, { sync_backend: chosen });
|
||||
if (updated?.sync_downgraded && updated?.sync_reason) {
|
||||
showToast(t('dashboard.group_sync.toast_downgraded') + ' ' + updated.sync_reason, 'warning');
|
||||
} else {
|
||||
showToast(t('dashboard.group_sync.toast_backend'), 'success');
|
||||
}
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
e.target.value = previous;
|
||||
}
|
||||
});
|
||||
sel.dataset.previous = sel.value;
|
||||
});
|
||||
|
||||
// #group-sync: manual "Resync now" — nudge all members to re-snap to the shared schedule.
|
||||
document.querySelectorAll('.group-resync-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
|
|
@ -879,10 +1013,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
|
||||
try {
|
||||
const result = await api.sendGroupCommand(groupId, type);
|
||||
const msg = result.offline > 0
|
||||
// A group is routinely mixed-platform, so these buttons stay visible — "reboot" is
|
||||
// meaningful for the Android panels in the group even when the web players in it can
|
||||
// never honour it. What must not happen is the toast counting those as sent: the
|
||||
// operator would walk away believing the whole group rebooted.
|
||||
let msg = result.offline > 0
|
||||
? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline })
|
||||
: t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total });
|
||||
showToast(msg, result.offline > 0 ? 'warning' : 'success');
|
||||
if (result.unsupported > 0) {
|
||||
msg += ' ' + t('dashboard.toast.command_unsupported_n', { n: result.unsupported });
|
||||
}
|
||||
showToast(msg, (result.offline > 0 || result.unsupported > 0) ? 'warning' : 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,25 @@ 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
|
||||
// customer reasonably read the blank as a bug in the player. "permission" means we are not
|
||||
// allowed to know; empty means there is genuinely no Wi-Fi (an Ethernet panel).
|
||||
function ssidLabel(ssid) {
|
||||
if (ssid === 'permission') return esc(t('device.info.wifi_needs_location'));
|
||||
if (!ssid) return '--';
|
||||
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;
|
||||
|
|
@ -14,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).
|
||||
|
|
@ -73,6 +167,49 @@ function renderDeviceClock(device) {
|
|||
return `${tz}${local ? `<div style="font-size:11px;color:var(--text-muted)">${t('device.clock.reported', { time: local })}</div>` : ''}${warn}`;
|
||||
}
|
||||
|
||||
// A BrightSign runs the same web player, so client_type is 'player' and it would otherwise read as
|
||||
// "Web Player" — indistinguishable from a browser tab on someone's desk. The player reports
|
||||
// platform 'brightsign' (autorun.brs puts ?platform=brightsign on the URL); the user-agent check
|
||||
// covers panels paired before that existed, which registered as "Chrome 120" with a BrightSign UA.
|
||||
function isBrightSignDevice(device) {
|
||||
if (!device) return false;
|
||||
// platform only: `devices` has no user_agent column, so a fallback on it could never fire.
|
||||
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">
|
||||
|
|
@ -124,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');
|
||||
|
|
@ -151,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);
|
||||
|
|
@ -172,26 +315,43 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
try {
|
||||
const device = await api.getDevice(deviceId);
|
||||
currentDevice = device;
|
||||
|
||||
/*
|
||||
* Does this display support `cap`? Drives which controls render at all.
|
||||
*
|
||||
* Every control used to be offered to every display: a browser tab was shown "Reboot device",
|
||||
* a Tizen TV was shown screen power. They did nothing, silently, and read as bugs. Hidden
|
||||
* rather than disabled — a greyed-out button on a panel that will NEVER gain the capability is
|
||||
* a permanent question ("what do I have to do to enable this?") with no answer. The capability
|
||||
* list is shown in the Info tab so a missing control is explainable.
|
||||
*
|
||||
* The server resolves the baseline for the ~440 displays that declare nothing, so this sees a
|
||||
* populated list either way and never has to know the difference.
|
||||
*/
|
||||
const caps = Array.isArray(device.capabilities) ? device.capabilities : null;
|
||||
const can = (cap) => (caps ? caps.includes(cap) : true); // no list at all => pre-capability server, show everything
|
||||
|
||||
const latestTelemetry = device.telemetry?.[0] || {};
|
||||
const diagWidget = (device.assignments || []).find(a => a && a.widget_type === 'diag-smoothness');
|
||||
|
||||
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>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-secondary btn-sm" id="devicePreviewBtn">${t('device.preview_btn')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="renameBtn">${t('device.rename')}</button>
|
||||
${can('remote.screenshot') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="screenshotBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
${t('device.screenshot_btn')}
|
||||
</button>
|
||||
</button>` : ''}
|
||||
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||
<button class="btn btn-secondary btn-sm" id="deviceOwnerBtn" title="${t('device.owner_provision.tip')}">${t('device.owner_provision.btn')}</button>` : ''}
|
||||
<button class="btn btn-secondary btn-sm" id="blockDeviceBtn">${device.blocked ? 'Unblock' : 'Block'}</button>
|
||||
|
|
@ -199,27 +359,31 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${device.tier === 2 ? `
|
||||
${/* tier===2 is kept alongside the capability: it is already an accurate RUNTIME signal from
|
||||
the panel, and a device-owner display that has not yet shipped a capability declaration
|
||||
would otherwise lose these buttons the day this deploys. */
|
||||
(device.tier === 2 || can('system.device_owner')) ? `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:8px 0 4px" title="${t('device.tier2.tip')}">
|
||||
<span style="font-size:12px;color:var(--text-muted)">${t('device.tier2.label')}</span>
|
||||
<button class="btn btn-secondary btn-sm" id="t2Reboot">${t('device.tier2.reboot')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="t2Lock">${t('device.tier2.lock')}</button>
|
||||
${(device.tier === 2 || can('system.kiosk')) ? `
|
||||
<button class="btn btn-secondary btn-sm" id="t2KioskOn">${t('device.tier2.kiosk_on')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="t2KioskOff">${t('device.tier2.kiosk_off')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="t2KioskOff">${t('device.tier2.kiosk_off')}</button>` : ''}
|
||||
</div>` : ''}
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="nowplaying">${t('device.tab.now_playing')} <span class="help-tip" data-tip="${t('device.tab.now_playing_tip')}">?</span></div>
|
||||
<div class="tab" data-tab="playlist">${t('device.tab.playlist')} <span class="help-tip" data-tip="${t('device.tab.playlist_tip')}">?</span></div>
|
||||
<div class="tab" data-tab="info">${t('device.tab.info')} <span class="help-tip" data-tip="${t('device.tab.info_tip')}">?</span></div>
|
||||
<div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>
|
||||
${(device.client_type === 'apk' || device.android_version) ? `<div class="tab" data-tab="controls">${t('device.tab.controls')} <span class="help-tip" data-tip="${t('device.tab.controls_tip')}">?</span></div>` : ''}
|
||||
${(can('remote.stream') || can('remote.input') || can('remote.screenshot')) ? `<div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>` : ''}
|
||||
${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `<div class="tab" data-tab="controls">${t('device.tab.controls')} <span class="help-tip" data-tip="${t('device.tab.controls_tip')}">?</span></div>` : ''}
|
||||
${device.tier === 2 ? `<div class="tab" data-tab="terminal">${t('device.tab.terminal')} <span class="help-tip" data-tip="${t('device.tab.terminal_tip')}">?</span></div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
|
|
@ -228,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>
|
||||
|
|
@ -293,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>
|
||||
|
|
@ -302,6 +526,22 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.info.ip_address')}</div>
|
||||
<div class="info-card-value small">${device.ip_address || '--'}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<!-- Two different addresses, and conflating them confused a customer into reading their
|
||||
ISP's address as the screen's. Above is where the connection comes FROM (public);
|
||||
this is what the screen calls itself on its own network. -->
|
||||
<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>
|
||||
|
|
@ -324,13 +564,64 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
` : `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.player_type')}</div>
|
||||
<div class="info-card-value small">${t('device.info.web_player')}</div>
|
||||
<div class="info-card-value small">${isBrightSignDevice(device) ? t('device.info.brightsign_player') : t('device.info.web_player')}</div>
|
||||
</div>
|
||||
${device.hardware_model ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.hardware_model')}</div>
|
||||
<div class="info-card-value small">${esc(device.hardware_model)}${device.output_index > 1 ? ` <span style="color:var(--text-muted)">${t('device.info.output_n', { n: device.output_index })}</span>` : ''}</div>
|
||||
</div>` : ''}
|
||||
${device.hardware_os_version ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.os_version')}</div>
|
||||
<div class="info-card-value small">${esc(device.hardware_os_version)}</div>
|
||||
</div>` : ''}
|
||||
${device.hardware_serial ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.serial')}</div>
|
||||
<div class="info-card-value small">${esc(device.hardware_serial)}</div>
|
||||
</div>` : ''}
|
||||
${latestTelemetry.storage_total_mb ? `
|
||||
<div class="info-card">
|
||||
<!-- 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'}"
|
||||
style="width:${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb * 100)}%"></div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
`}
|
||||
${latestTelemetry.temperature_c != null ? `
|
||||
<div class="info-card">
|
||||
<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>
|
||||
<div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div>
|
||||
<div class="info-card-value small" id="telWifi">${ssidLabel(latestTelemetry.wifi_ssid)}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
|
@ -351,6 +642,10 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.info.settings_pin')}</div>
|
||||
<div class="info-card-value small" style="font-family:monospace;letter-spacing:1px">${device.settings_pin || '--'}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('device.info.settings_pin_hint')}</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button class="btn btn-secondary btn-sm" id="rotatePinBtn">${t('device.pin.rotate')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="setPinBtn">${t('device.pin.set')}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="info-card">
|
||||
|
|
@ -368,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>
|
||||
|
|
@ -380,6 +682,22 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- What this display can do.
|
||||
Controls are now hidden when the player cannot honour them, which on its own looks
|
||||
like the dashboard has lost features. This is the answer to "where did the reboot
|
||||
button go" — it names the exact set the panel reported, and says plainly when the set
|
||||
is a per-platform assumption rather than something the player actually declared. -->
|
||||
<div style="margin-top:20px">
|
||||
<h4 style="font-size:13px;margin-bottom:8px">${t('device.caps.title')}</h4>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
|
||||
${caps ? t('device.caps.declared') : t('device.caps.assumed')}
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||||
${(device.capabilities || []).map(c => `<span style="font-family:monospace;font-size:11px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;padding:2px 6px">${esc(c)}</span>`).join('')
|
||||
|| `<span style="font-size:12px;color:var(--danger)">${t('device.caps.none')}</span>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uptime Timeline (24h) -->
|
||||
<div style="margin-top:20px">
|
||||
<h4 style="font-size:13px;margin-bottom:8px">${t('device.timeline.title')}</h4>
|
||||
|
|
@ -429,6 +747,10 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<input type="checkbox" id="otaToggle" ${device.ota_enabled === 0 ? '' : 'checked'}> ${t('device.ota.toggle')}
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.ota.hint')}</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;margin-top:8px">
|
||||
<input type="checkbox" id="otaBetaToggle" ${device.ota_beta === 1 ? 'checked' : ''}> ${t('device.ota.beta')}
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.ota.beta_hint')}</div>
|
||||
</div>
|
||||
<div class="form-group" style="max-width:280px">
|
||||
<label>${t('device.reboot_schedule.label')}</label>
|
||||
|
|
@ -444,47 +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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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-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). -->
|
||||
|
|
@ -510,10 +804,17 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${(can('remote.stream') || can('remote.input') || can('remote.screenshot')) ? `
|
||||
<!-- Remote Control Tab -->
|
||||
<div class="tab-content" id="tab-remote">
|
||||
<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">
|
||||
|
|
@ -525,12 +826,14 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<p style="color:var(--text-secondary)">${t('device.remote.start_prompt')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="remote-controls">
|
||||
${can('remote.stream') ? `
|
||||
<button class="btn btn-primary" id="startRemoteBtn">${t('device.remote.start')}</button>
|
||||
<button class="btn btn-secondary" id="stopRemoteBtn" style="display:none">${t('device.remote.stop')}</button>
|
||||
<hr style="border-color:var(--border);margin:8px 0">
|
||||
<!-- Always available -->
|
||||
<hr style="border-color:var(--border);margin:8px 0">` : ''}
|
||||
${can('remote.input') ? `
|
||||
<!-- Key pad -->
|
||||
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_UP')">${t('device.remote.vol_up')}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">${t('device.remote.vol_down')}</button>
|
||||
<hr style="border-color:var(--border);margin:8px 0">
|
||||
|
|
@ -551,33 +854,37 @@ 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>` : ''}
|
||||
</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>
|
||||
` : `
|
||||
${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>
|
||||
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>`}
|
||||
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>` : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${(device.client_type === 'apk' || device.android_version) ? `
|
||||
${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `
|
||||
<!-- Controls Tab (#160 Track-A system control — no device owner needed) -->
|
||||
<div class="tab-content" id="tab-controls">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">${t('device.sysctl.subtitle')}</div>
|
||||
<div style="display:grid;grid-template-columns:130px 1fr;gap:14px 14px;align-items:center;font-size:13px;max-width:480px">
|
||||
${can('audio.volume') ? `
|
||||
<label>${t('device.sysctl.volume')}</label>
|
||||
<input type="range" min="0" max="100" value="${Math.round((device.media_volume != null ? device.media_volume : 0.5) * 100)}" id="sysVolume" style="width:100%">
|
||||
<input type="range" min="0" max="100" value="${Math.round((device.media_volume != null ? device.media_volume : 0.5) * 100)}" id="sysVolume" style="width:100%">` : ''}
|
||||
${can('display.brightness') ? `
|
||||
<label>${t('device.sysctl.brightness_window')}</label>
|
||||
<input type="range" min="5" max="100" value="${Math.round((device.window_brightness != null && device.window_brightness >= 0 ? device.window_brightness : 1) * 100)}" id="sysWinBrightness" style="width:100%">
|
||||
${(device.can_write_settings || device.tier === 2) ? `
|
||||
<input type="range" min="5" max="100" value="${Math.round((device.window_brightness != null && device.window_brightness >= 0 ? device.window_brightness : 1) * 100)}" id="sysWinBrightness" style="width:100%">` : ''}
|
||||
${(device.can_write_settings || device.tier === 2 || can('system.brightness') || can('system.screen_timeout')) ? `
|
||||
<label>${t('device.sysctl.brightness_system')}</label>
|
||||
<input type="range" min="5" max="100" value="${Math.round((device.system_brightness != null ? device.system_brightness : 0.8) * 100)}" id="sysBrightness" style="width:100%">
|
||||
<label>${t('device.sysctl.sleep')}</label>
|
||||
|
|
@ -679,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);
|
||||
|
|
@ -688,9 +996,14 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
if (activeTab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
const tab = document.querySelector(`.tab[data-tab="${activeTab}"]`);
|
||||
// Both loops above just cleared every tab, so a requested tab that no longer renders (its
|
||||
// capability went away, or the page was reloaded against a player that has since declared a
|
||||
// smaller set) would leave NO tab selected and the page blank. Fall back to Info, which is
|
||||
// never gated.
|
||||
const wanted = document.getElementById(`tab-${activeTab}`) ? activeTab : 'info';
|
||||
const tab = document.querySelector(`.tab[data-tab="${wanted}"]`);
|
||||
if (tab) tab.classList.add('active');
|
||||
const content = document.getElementById(`tab-${activeTab}`);
|
||||
const content = document.getElementById(`tab-${wanted}`);
|
||||
if (content) content.classList.add('active');
|
||||
}
|
||||
|
||||
|
|
@ -783,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 = `
|
||||
|
|
@ -794,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(); };
|
||||
|
|
@ -904,12 +1225,43 @@ async function showReAdoptModal(device) {
|
|||
|
||||
function setupActions(device) {
|
||||
// #104 Preview button
|
||||
// PIN rotate / set. The response says whether the panel took it LIVE: an offline display
|
||||
// applies it on its next reconnect, and an operator rotating a leaked PIN needs to know
|
||||
// which of those happened rather than assuming access is already revoked.
|
||||
async function applyPin(body, confirmMsg) {
|
||||
if (confirmMsg && !confirm(confirmMsg)) return;
|
||||
try {
|
||||
const r = await api.setDevicePin(device.id, body);
|
||||
device.settings_pin = r.settings_pin;
|
||||
const el = document.querySelector('#rotatePinBtn')?.closest('.info-card')?.querySelector('.info-card-value');
|
||||
if (el) el.textContent = r.settings_pin;
|
||||
showToast(r.delivered ? t('device.pin.updated_live') : t('device.pin.updated_offline'), 'success');
|
||||
} catch (e) {
|
||||
showToast(e?.message || t('device.pin.failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('rotatePinBtn')?.addEventListener('click', () =>
|
||||
applyPin({ rotate: true }, t('device.pin.rotate_confirm')));
|
||||
|
||||
document.getElementById('setPinBtn')?.addEventListener('click', () => {
|
||||
const pin = prompt(t('device.pin.set_prompt'));
|
||||
if (pin === null) return;
|
||||
applyPin({ pin });
|
||||
});
|
||||
|
||||
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
|
||||
|
|
@ -950,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, {
|
||||
|
|
@ -960,6 +1339,7 @@ function setupActions(device) {
|
|||
orientation: document.getElementById('deviceOrientation').value,
|
||||
default_content_id: document.getElementById('deviceDefaultContent').value || null,
|
||||
ota_enabled: document.getElementById('otaToggle')?.checked ? 1 : 0,
|
||||
ota_beta: document.getElementById('otaBetaToggle')?.checked ? 1 : 0,
|
||||
reboot_schedule: document.getElementById('rebootSchedule')?.value || null,
|
||||
});
|
||||
showToast(t('device.toast.settings_saved'), 'success');
|
||||
|
|
@ -1022,10 +1402,15 @@ function setupActions(device) {
|
|||
|
||||
playlistPicker.addEventListener('change', async () => {
|
||||
const newPlaylistId = playlistPicker.value;
|
||||
if (!newPlaylistId) return; // Don't allow deselecting for now
|
||||
try {
|
||||
await api.assignPlaylistToDevice(newPlaylistId, device.id);
|
||||
device.playlist_id = newPlaylistId;
|
||||
// Empty value is the "No playlist" option. It used to be discarded right here, so the
|
||||
// option was offered, selecting it did nothing, and nothing said so (#234).
|
||||
if (newPlaylistId) {
|
||||
await api.assignPlaylistToDevice(newPlaylistId, device.id);
|
||||
} else {
|
||||
await api.clearDevicePlaylist(device.id);
|
||||
}
|
||||
device.playlist_id = newPlaylistId || null;
|
||||
const assignments = await api.getAssignments(device.id);
|
||||
const pc = document.getElementById('playlistContainer');
|
||||
pc.innerHTML = renderPlaylist(assignments);
|
||||
|
|
@ -1132,13 +1517,18 @@ function setupActions(device) {
|
|||
}, 3000);
|
||||
});
|
||||
|
||||
// Send a command and surface the three-state ack as a toast.
|
||||
// Send a command and surface the ack as a toast.
|
||||
// - delivered: device received it (green/success)
|
||||
// - queued: device is offline, will deliver on reconnect (amber/warning)
|
||||
// - unsupported: the player cannot do this at all (red/error, names the capability)
|
||||
// - no_ack / fallback: server didn't respond or queue unavailable (red/error)
|
||||
function sendWithFeedback(type, cmdLabel, successKey) {
|
||||
sendCommand(device.id, type, {}, (ack) => {
|
||||
if (ack?.delivered) showToast(t(successKey), 'success');
|
||||
// Reachable from a stale tab rendered before the panel declared its capabilities: the
|
||||
// button was there when the page loaded and is gone on reload. Say why rather than
|
||||
// showing the generic "undeliverable", which reads as a network problem.
|
||||
else if (ack?.reason === 'unsupported') showToast(t('device.toast.command_unsupported', { cmd: cmdLabel, cap: ack.capability || '' }), 'error');
|
||||
else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning');
|
||||
else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error');
|
||||
else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error');
|
||||
|
|
@ -1209,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();
|
||||
|
|
@ -1391,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>
|
||||
|
|
@ -1403,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">
|
||||
|
|
@ -1414,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
|
||||
|
|
@ -1438,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>
|
||||
|
|
@ -1447,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>
|
||||
|
|
@ -1474,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;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1868,7 +2275,11 @@ function updateTelemetryDisplay(telemetry) {
|
|||
};
|
||||
if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%');
|
||||
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
|
||||
if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid);
|
||||
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) }));
|
||||
|
|
@ -1945,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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t, tn } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
|
|
@ -104,6 +117,20 @@ function renderLayoutCard(layout, isTemplate) {
|
|||
`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Canvas aspect as a padding-top percentage: the layout's own height/width.
|
||||
*
|
||||
* Falls back to 16:9 when a layout carries no usable dimensions, and clamps so a pathological
|
||||
* value cannot produce a canvas taller than the screen or thinner than a line — these rows are
|
||||
* user-editable, and an unusable editor is worse than a slightly wrong aspect.
|
||||
*/
|
||||
function canvasRatioPct(layout) {
|
||||
const w = Number(layout && layout.width) || 1920;
|
||||
const h = Number(layout && layout.height) || 1080;
|
||||
if (!(w > 0 && h > 0)) return 56.25;
|
||||
return Math.min(300, Math.max(20, (h / w) * 100));
|
||||
}
|
||||
|
||||
async function renderEditor(container, layoutId) {
|
||||
let layout;
|
||||
try {
|
||||
|
|
@ -116,7 +143,12 @@ async function renderEditor(container, layoutId) {
|
|||
${t('layout.back')}
|
||||
</a>
|
||||
<div class="page-header">
|
||||
<h1 id="layoutName">${esc(layout.name)}</h1>
|
||||
<!-- Editable in place. Duplicating a template names the copy "<template> (Copy)" and there
|
||||
was nowhere at all to change it — the only name field in this editor belongs to the
|
||||
selected ZONE, which is easy to mistake for the layout's own. Reported on #234. -->
|
||||
<input id="layoutName" class="input" value="${esc(layout.name)}"
|
||||
aria-label="${t('layout.rename')}" title="${t('layout.rename')}"
|
||||
style="font-size:24px;font-weight:600;background:transparent;border:1px solid transparent;padding:2px 6px;max-width:420px">
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
|
||||
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
|
||||
|
|
@ -125,7 +157,10 @@ async function renderEditor(container, layoutId) {
|
|||
<div style="display:flex;gap:20px">
|
||||
<div style="flex:1">
|
||||
<div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden">
|
||||
<div id="canvas" style="position:relative;width:100%;padding-top:56.25%">
|
||||
<!-- Canvas mirrors THIS layout's shape, not a fixed 16:9. It was hardcoded to 56.25%
|
||||
(the padding-ratio trick for 16:9), so authoring a portrait layout meant dragging
|
||||
zones on a landscape canvas: correct on the panel, wrong everywhere you designed it. -->
|
||||
<div id="canvas" style="position:relative;width:100%;padding-top:${canvasRatioPct(layout)}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -297,9 +332,12 @@ async function renderEditor(container, layoutId) {
|
|||
// exactly. The old per-zone delete-then-add loop could accumulate zones
|
||||
// (and regenerated every zone id each save). Keep each zone's id so
|
||||
// device->zone assignments survive.
|
||||
const newName = (document.getElementById('layoutName')?.value || '').trim();
|
||||
const updated = await API(`/layouts/${layoutId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ zones }),
|
||||
// Name goes with the zones so renaming is part of the Save the user already
|
||||
// presses, not a second hidden action.
|
||||
body: JSON.stringify(newName ? { zones, name: newName } : { zones }),
|
||||
});
|
||||
if (updated && updated.error) { showToast(updated.error, 'error'); return; }
|
||||
layout = updated;
|
||||
|
|
|
|||
|
|
@ -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,150 @@ 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);
|
||||
}
|
||||
|
||||
/*
|
||||
* A small picture of where this playlist's content actually lands.
|
||||
*
|
||||
* A playlist has no intrinsic layout — the server derives one from the items' own zone bindings
|
||||
* (#104) — so the page could previously show an item tagged "Bottom Ticker" with no indication
|
||||
* that the ticker is a thin strip along the bottom. People assigned content to zones by name and
|
||||
* found out where it went by looking at a screen.
|
||||
*
|
||||
* Drawn from the zone percentages, so it is correct for any layout including portrait ones without
|
||||
* a stored thumbnail. Zones with no items are dimmed: an empty zone on a real panel shows its
|
||||
* background colour, and that is worth seeing BEFORE publishing rather than after.
|
||||
*/
|
||||
function layoutMockup(playlist) {
|
||||
const layout = playlist && playlist.layout;
|
||||
const items = (playlist && playlist.items) || [];
|
||||
|
||||
// No layout means fullscreen — every item shares one frame. Drawing a single empty box would
|
||||
// imply a choice was made; say it in words instead.
|
||||
if (!layout || !Array.isArray(layout.zones) || layout.zones.length === 0) {
|
||||
return `<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('playlist.layout_fullscreen')}</div>`;
|
||||
}
|
||||
|
||||
const counts = {};
|
||||
for (const it of items) if (it.zone_id) counts[it.zone_id] = (counts[it.zone_id] || 0) + 1;
|
||||
|
||||
const w = Number(layout.width) || 1920;
|
||||
const h = Number(layout.height) || 1080;
|
||||
const portrait = h > w;
|
||||
// Fixed short edge, long edge derived — a portrait mockup must not be as wide as a landscape one
|
||||
// or it dominates the page.
|
||||
const boxW = portrait ? 90 : 200;
|
||||
const boxH = Math.round(boxW * (h / w));
|
||||
|
||||
const zones = layout.zones.map((z) => {
|
||||
const n = counts[z.id] || 0;
|
||||
const filled = n > 0;
|
||||
return `<div title="${esc(z.name)}${filled ? ` — ${n}` : ''}" style="
|
||||
position:absolute;
|
||||
left:${z.x_percent}%; top:${z.y_percent}%;
|
||||
width:${z.width_percent}%; height:${z.height_percent}%;
|
||||
box-sizing:border-box;
|
||||
border:1px solid ${filled ? 'var(--accent)' : 'var(--border)'};
|
||||
background:${filled ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent'};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:9px;line-height:1;color:var(--text-muted);overflow:hidden;
|
||||
">${filled ? n : ''}</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||
<div style="position:relative;width:${boxW}px;height:${boxH}px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;flex:none">
|
||||
${zones}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted)">
|
||||
<div>${esc(layout.name || '')} · ${w}×${h}${portrait ? ' (portrait)' : ''}</div>
|
||||
<div>${tn('playlist.zones_count', layout.zones.length)}</div>
|
||||
${layout._preview_ambiguous ? `<div style="color:var(--warning)">${t('playlist.layout_ambiguous')}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDetailContent(container, playlist) {
|
||||
|
|
@ -288,6 +415,8 @@ function renderDetailContent(container, playlist) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${layoutMockup(playlist)}
|
||||
|
||||
<div id="playlistItems" style="display:flex;flex-direction:column;gap:8px">
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -672,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">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { esc } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const devices = await api.getDevices();
|
||||
|
|
@ -26,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>
|
||||
|
|
@ -169,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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,20 @@ import {
|
|||
dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN,
|
||||
} from '../lib/schedule-grid.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
// Teardown registered during render (resize listener, etc). Declared here rather than beside
|
||||
// cleanup() so it is initialised before any render can push to it.
|
||||
|
|
|
|||
|
|
@ -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('')}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t, tn } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t } from '../i18n.js';
|
||||
import { hydrateAuthImages } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
// Widget type ids only — name + desc are looked up via t() so they switch
|
||||
// language with the rest of the UI.
|
||||
|
|
@ -200,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">
|
||||
|
|
@ -294,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>`;
|
||||
|
|
@ -387,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>`;
|
||||
|
|
@ -416,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);
|
||||
111
scripts/build-autorun-zip.sh
Executable file
111
scripts/build-autorun-zip.sh
Executable file
|
|
@ -0,0 +1,111 @@
|
|||
#!/bin/bash
|
||||
# Build brightsign/autorun.zip — the single-file installer for a BrightSign player.
|
||||
#
|
||||
# scripts/build-autorun-zip.sh [--server https://your-server] [-o path/to/autorun.zip]
|
||||
#
|
||||
# Drop the resulting autorun.zip on the root of a player's storage (microSD, USB, or internal
|
||||
# flash over SFTP) and power-cycle. autozip.brs unpacks it in place, marks it done, and reboots
|
||||
# into the player. One file to distribute instead of four that must all land intact.
|
||||
#
|
||||
# ⚠️ The zip must expand to files AT ITS ROOT — no wrapper directory. A player extracts to the
|
||||
# storage root, so a nested folder puts autorun.brs somewhere the player never looks and the
|
||||
# card silently does nothing. That is why this zips from *inside* the staging directory.
|
||||
#
|
||||
# ⚠️ autorun.brs must NOT sit next to autorun.zip on the storage root: its presence stops the zip
|
||||
# being processed at all. It belongs inside, which is where this puts it.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SERVER=""
|
||||
OUT="brightsign/autorun.zip"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--server) SERVER="${2:-}"; shift 2 ;;
|
||||
-o|--out) OUT="${2:-}"; shift 2 ;;
|
||||
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v zip >/dev/null || { echo "ERROR: 'zip' is not installed." >&2; exit 1; }
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
# The payload. autozip.brs must be here too: it is what the NEXT player to receive this archive
|
||||
# runs, and it has to survive being extracted alongside everything else.
|
||||
cp brightsign/autozip.brs "$STAGE/"
|
||||
cp brightsign/autorun.brs "$STAGE/"
|
||||
cp brightsign/offline.html "$STAGE/"
|
||||
cp brightsign/screentinker.json "$STAGE/"
|
||||
|
||||
# Stamp the version into the host so the script REPORTS the version it actually is. A package that
|
||||
# ships reporting the old version is applied, reports the old version, and is offered again on the
|
||||
# next check — forever. server/lib/brightsign-package.js does the identical substitution, anchored
|
||||
# on the same ST_PACKAGE_VERSION marker, so a zip built here and one built by the server agree.
|
||||
VERSION="$(cat VERSION 2>/dev/null | tr -d '[:space:]')"
|
||||
if [ -n "$VERSION" ]; then
|
||||
python3 - "$STAGE/autorun.brs" "$VERSION" <<'PY'
|
||||
import re, sys
|
||||
path, version = sys.argv[1], sys.argv[2]
|
||||
src = open(path).read()
|
||||
out = re.sub(r'return "[^"]*"(\s*\'\s*ST_PACKAGE_VERSION)', 'return "%s"\\1' % version, src)
|
||||
if out == src:
|
||||
sys.exit("ERROR: ST_PACKAGE_VERSION marker not found in autorun.brs — refusing to ship an "
|
||||
"unstamped package, which would loop on every update check.")
|
||||
open(path, 'w').write(out)
|
||||
PY
|
||||
echo " stamped package version $VERSION"
|
||||
fi
|
||||
|
||||
# Point a batch at a specific server without hand-editing each card.
|
||||
if [ -n "$SERVER" ]; then
|
||||
python3 - "$STAGE/screentinker.json" "$SERVER" <<'PY'
|
||||
import json, sys
|
||||
path, server = sys.argv[1], sys.argv[2]
|
||||
cfg = json.load(open(path))
|
||||
cfg['server_url'] = server
|
||||
json.dump(cfg, open(path, 'w'), indent=2)
|
||||
PY
|
||||
echo " server_url set to $SERVER"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
rm -f "$OUT"
|
||||
ABS_OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
|
||||
|
||||
# -0 = STORED, no compression. This is not a size/speed preference, it is a compatibility
|
||||
# requirement: BrightSign's automated deployment reported our first archive as invalid and could
|
||||
# not open it. The player bootstrap extracts autozip.brs by itself, before any script runs, and
|
||||
# roBrightPackage documents a specific set of supported methods — "no compression" is the one that
|
||||
# is universally safe. A deflated archive copies onto the player perfectly and then fails to open,
|
||||
# which looks like a broken deployment rather than a broken zip.
|
||||
#
|
||||
# -X drops extra attributes; -j would flatten any directories added later, so instead cd in and zip
|
||||
# '.' so the archive root IS the staging root and future subdirectories keep their structure.
|
||||
( cd "$STAGE" && zip -q -r -X -0 "$ABS_OUT" . )
|
||||
|
||||
echo " built $OUT"
|
||||
unzip -l "$OUT" | sed 's/^/ /'
|
||||
|
||||
# Prove the root-level invariant rather than trusting it: this is the one mistake that makes a
|
||||
# card look blank to the player, and it is invisible until hardware refuses to boot.
|
||||
if unzip -l "$OUT" | awk 'NR>3 && $4 ~ /\// && $4 !~ /^[^\/]+$/ {print $4}' | grep -qE '^[^/]+/'; then
|
||||
echo " NOTE: archive contains directories — verify they are intended subdirectories, not a wrapper."
|
||||
fi
|
||||
if ! unzip -l "$OUT" | grep -qE ' autorun\.brs$'; then
|
||||
echo "ERROR: autorun.brs is not at the archive root — the player would never find it." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! unzip -l "$OUT" | grep -qE ' autozip\.brs$'; then
|
||||
echo "ERROR: autozip.brs is missing — nothing would unpack this archive." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Prove every entry is STORED. A single deflated member is enough to make the archive unopenable
|
||||
# on the player, and it is invisible until a deployment fails in the field.
|
||||
if unzip -v "$OUT" | awk '$1 ~ /^[0-9]+$/ && $2 ~ /^[A-Za-z]/ && $2 != "Stored" {print $2}' | grep -q .; then
|
||||
echo "ERROR: archive contains compressed members; BrightSign needs it stored (zip -0)." >&2
|
||||
unzip -v "$OUT" | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " root-level layout verified, all members stored"
|
||||
|
|
@ -80,8 +80,28 @@ sed -i -E "s/(versionCode.*\?:[[:space:]]*)\"[0-9]+\"/\1\"$((CODE + 1))\"/" andr
|
|||
NUMERIC="${NEW%%-*}"
|
||||
sed -i -E "/^<\?xml/! s/([[:space:]]version=\")[0-9][^\"]*(\")/\1${NUMERIC}\2/" tizen/config.xml
|
||||
|
||||
# 5) commit + annotated tag (no push)
|
||||
git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml
|
||||
# 5) public API spec version. This is the number Redoc prints at the top of the published
|
||||
# API reference (frontend/api-docs.html renders docs/openapi.yaml directly), so leaving it
|
||||
# behind means customers read a version that has not existed for months — it had drifted to
|
||||
# 1.9.0 while shipping 1.9.25 precisely because this step did not exist. Anchored to the
|
||||
# two-space ` version:` under `info:`; operation-level and schema-level keys are indented
|
||||
# deeper and are not touched. As with Tizen, use the numeric form: the spec version is a
|
||||
# published API identity, not a build label.
|
||||
sed -i -E "0,/^ version:/s/^( version:[[:space:]]*).*/\1${NUMERIC}/" docs/openapi.yaml
|
||||
|
||||
# 6) CHANGELOG guard. Deliberately NOT auto-generated — a generated changelog reads like
|
||||
# documentation while saying nothing, and the entry has to come from whoever knows what
|
||||
# shipped. This only refuses to let a release be cut silently without one, which is how the
|
||||
# file fell 23 versions behind.
|
||||
if ! grep -q "^## ${NEW}$" CHANGELOG.md 2>/dev/null; then
|
||||
echo
|
||||
echo " WARNING: CHANGELOG.md has no '## $NEW' entry."
|
||||
echo " Add one before pushing the tag — the release notes are read from it."
|
||||
echo
|
||||
fi
|
||||
|
||||
# 7) commit + annotated tag (no push)
|
||||
git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml docs/openapi.yaml
|
||||
git commit -q -m "chore(release): v$NEW"
|
||||
git tag -a "v$NEW" -m "ScreenTinker v$NEW"
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue