mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 23:03:14 -06:00
Compare commits
35 commits
v1.9.34-al
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9459139f | ||
|
|
94a81b6896 | ||
|
|
86db5929c1 | ||
|
|
3ec06b663c | ||
|
|
8cb67122ad | ||
|
|
b13f11af13 | ||
|
|
dd7295792e | ||
|
|
114dc453bb | ||
|
|
955a691bcd | ||
|
|
702e107972 | ||
|
|
04a2ad99d1 | ||
|
|
243fc6688c | ||
|
|
741bc7b6a3 | ||
|
|
60dacad303 | ||
|
|
463e21f4d9 | ||
|
|
cb4b491840 | ||
|
|
23e80f2d27 | ||
|
|
d6c1a36d8c | ||
|
|
ab9ce40997 | ||
|
|
76f1fb7e7a | ||
|
|
66df64a798 | ||
|
|
66b5a9cc9d | ||
|
|
26c059c1b8 | ||
|
|
414c1e9ab5 | ||
|
|
8b0601b7bc | ||
|
|
feda25943c | ||
|
|
0b10776701 | ||
|
|
521025a073 | ||
|
|
58fdf8122c | ||
|
|
13534d9b61 | ||
|
|
b906bfa65f | ||
|
|
e9bd8ac8af | ||
|
|
8b162ecce2 | ||
|
|
7934156a2e | ||
|
|
3234c923a3 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
|
@ -86,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
|
||||
|
|
@ -109,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"
|
||||
|
|
@ -131,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
|
||||
|
|
|
|||
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
|
|
@ -85,6 +85,15 @@ jobs:
|
|||
./scripts/build-autorun-zip.sh -o autorun.zip
|
||||
ls -la autorun.zip
|
||||
|
||||
# A published SBOM is what turns "we track licences" into something a customer or an
|
||||
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
|
||||
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
|
||||
- name: Generate SBOM (production dependencies)
|
||||
run: |
|
||||
( cd server && npm ci --omit=dev )
|
||||
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
|
||||
ls -la screentinker-sbom-*.cdx.json
|
||||
|
||||
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
|
||||
run: |
|
||||
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
|
||||
|
|
@ -100,15 +109,42 @@ jobs:
|
|||
- name: Generate release notes
|
||||
run: |
|
||||
PREV="${{ steps.ver.outputs.prev }}"
|
||||
VERSION="${{ steps.ver.outputs.version }}"
|
||||
|
||||
# Prefer the hand-written CHANGELOG section for this version.
|
||||
#
|
||||
# The generated list is commit SUBJECTS, which describe the work, not the release: cutting
|
||||
# 1.9.34 produced a page reading "chore(release): v1.9.34" and one changelog commit, while
|
||||
# the entry describing single sign-on, the native-dependency removal, the update fixes and
|
||||
# every outside contributor sat in CHANGELOG.md and was never published. The notes on the
|
||||
# release page are what most people actually read, so they should be the written ones.
|
||||
#
|
||||
# awk rather than sed: the body contains regex metacharacters and markdown that a sed range
|
||||
# would mangle. This takes everything between `## <version>` and the next `## ` heading.
|
||||
CHANGELOG_BODY="$(awk -v v="## $VERSION" '
|
||||
$0 == v {found=1; next}
|
||||
found && /^## / {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md)"
|
||||
|
||||
{
|
||||
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$PREV" ]; then
|
||||
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
|
||||
if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
|
||||
echo "$CHANGELOG_BODY"
|
||||
else
|
||||
echo "_First tagged release. Most recent changes:_"
|
||||
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
|
||||
# No entry for this version — fall back to commit subjects rather than publish a
|
||||
# release with no notes at all. scripts/bump-version.sh already warns when the
|
||||
# CHANGELOG has no matching heading; this is the same gap showing up downstream.
|
||||
echo "_No CHANGELOG entry for $VERSION; listing commits instead._"
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$PREV" ]; then
|
||||
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
|
||||
else
|
||||
echo "_First tagged release. Most recent changes:_"
|
||||
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
echo "### Artifacts"
|
||||
|
|
@ -127,6 +163,7 @@ jobs:
|
|||
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
|
||||
fi
|
||||
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
|
||||
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
|
||||
} > RELEASE_NOTES.md
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
|
|
@ -144,6 +181,7 @@ jobs:
|
|||
--notes-file RELEASE_NOTES.md \
|
||||
"${TARBALL}" \
|
||||
autorun.zip \
|
||||
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
|
||||
tizen/ScreenTinker.wgt
|
||||
|
||||
docker:
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
603
CHANGELOG.md
603
CHANGELOG.md
|
|
@ -1,271 +1,392 @@
|
|||
# Changelog
|
||||
|
||||
## 1.9.34-alpha7
|
||||
## 1.9.36
|
||||
|
||||
### ⚠️ Upgrading to this build requires reinstalling dependencies
|
||||
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
|
||||
|
||||
The two dependency changes below — dropping `sharp` and pinning `better-sqlite3` — alter
|
||||
`server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in both directions.
|
||||
The runbook's rollback step marks that command "only if dependencies changed"; for this release
|
||||
they did.
|
||||
### Fixed — 1.9.35 would not start on a server collecting install statistics
|
||||
|
||||
- **Upgrading**: `scripts/upgrade.sh` already runs it. A hand-rolled deploy that skips it leaves a
|
||||
`better-sqlite3` that no longer matches `package.json`.
|
||||
- **Rolling back past this build**: the reinstall is **mandatory**. Earlier builds import `sharp` at
|
||||
runtime to thumbnail images, and this build removes it from the production dependencies — so
|
||||
rolling back the code without reinstalling leaves a server whose image ingest cannot load its
|
||||
decoder. `lib/preflight-deps.js` catches this at boot and repairs it, but do not rely on that as
|
||||
the plan.
|
||||
A server with install-statistics collection switched on could not start 1.9.35. It threw
|
||||
`ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
|
||||
and a service manager configured to restart it would do so in a loop.
|
||||
|
||||
Docker deployments need no action either way: dependencies are installed inside the image.
|
||||
**Almost nobody is affected.** The fault is inside a block that only runs when a server is configured
|
||||
to *collect* statistics from other installs — not when it merely reports its own. That is a single
|
||||
deployment, not a normal install. If you have never set `TELEMETRY_COLLECTOR`, 1.9.35 runs correctly
|
||||
and this release changes nothing for you.
|
||||
|
||||
No migrations, no configuration changes, no player-side changes.
|
||||
The cause was a reference to the database resolved when the file loaded rather than when the request
|
||||
arrived, in code that had been moved earlier in the same release.
|
||||
|
||||
### Changed — the startup check now covers configuration only one deployment uses
|
||||
|
||||
The fault above shipped through a full test suite and every CI job green, because the affected block
|
||||
is switched on by configuration that no test set. It had never executed anywhere except the one
|
||||
server that turns it on.
|
||||
|
||||
The startup smoke check now boots with that configuration enabled and confirms the routes it adds
|
||||
actually answer. Code that only one deployment runs is exactly the code an automated check has to
|
||||
exercise, and it now does.
|
||||
|
||||
### Upgrading
|
||||
|
||||
No migrations, no configuration changes, and no dependency changes from 1.9.35 — this release only
|
||||
alters when one value is read. Upgrading from 1.9.34 or earlier, the 1.9.35 note still applies:
|
||||
`npm ci --omit=dev` is required in both directions, which `scripts/upgrade.sh` already runs.
|
||||
|
||||
## 1.9.35
|
||||
|
||||
A maintenance release. Two faults where the product was working correctly and still looked broken to
|
||||
whoever was standing in front of the screen, plus the dependency advisories that could reach a running
|
||||
server.
|
||||
|
||||
No migrations and no configuration changes. See the upgrade note at the end of this entry.
|
||||
|
||||
### Fixed — a player could get stuck on an update it was never able to install
|
||||
|
||||
A staged update is saved under a filename built from the version the server advertised. If a server
|
||||
advertised one version while still serving the file for an older one, the player saved the old file
|
||||
under the new name — and from then on found it, verified its signature, accepted it, and installed
|
||||
something that changed nothing. The version never moved, so the same update was offered again, and the
|
||||
player retried the same no-op until it hit its attempt limit.
|
||||
|
||||
The signature check passed the whole time, correctly: the file was genuine, it was simply the wrong
|
||||
one. Worse, fixing the server did not help, because the bad file was reused before anything was
|
||||
downloaded. Recovery meant deleting the file on the device by hand.
|
||||
|
||||
A staged update is now reused only when the version inside the file matches the version being
|
||||
installed, and a fresh download is checked the same way before it is applied. A server serving the
|
||||
wrong file now says so — *"server served 1.9.33 but advertised 1.9.34 — the update on the server is
|
||||
stale"* — and the file is deleted instead of kept. That makes this self-healing: once the server is
|
||||
corrected, the player recovers on its own.
|
||||
|
||||
**Clear update cache** on the device page discards every staged update on a player. The version check
|
||||
should make it rarely necessary; it exists because a player already holding a bad file predates this
|
||||
release and cannot benefit from the check, and because the alternative is a cable and a laptop.
|
||||
|
||||
### Fixed — directory search showed the system keyboard on top of its own
|
||||
|
||||
The directory-search widget draws its own on-screen keyboard, sized and themed to the panel and on by
|
||||
default. On Android it was never visible. The page puts the cursor in a real text field, which is the
|
||||
signal for the device to raise its system keyboard — over the bottom of the screen, exactly where the
|
||||
widget's keyboard is.
|
||||
|
||||
So a wall-mounted directory showed the phone keyboard: split across the screen, with microphone, GIF,
|
||||
emoji and a settings key that opens the keyboard vendor's own interface on a kiosk. On one panel the
|
||||
only keyboard installed was voice input, so touching the search box opened a microphone. The widget's
|
||||
own keyboard had been underneath the whole time.
|
||||
|
||||
When the widget draws a keyboard, it now tells the device not to raise one. Turn the built-in keyboard
|
||||
off and the system keyboard behaves as before — with nothing to cover, it is the only way left to type.
|
||||
|
||||
### Changed — the dependency advisories that could reach a running server are cleared
|
||||
|
||||
Every high-severity advisory affecting a production install is resolved, including eight in the mail
|
||||
library covering SMTP command injection and header injection. The remaining advisories are in
|
||||
development-only tooling that is not installed on a server and cannot be reached from one.
|
||||
|
||||
The real-time connection to players is deliberately untouched: the fix there was a patch to the message
|
||||
parser with no change to the format players speak, so nothing about an existing player's connection
|
||||
changes.
|
||||
|
||||
Sending mail was previously covered only by tests that substituted the mail library for a stand-in,
|
||||
which would have stayed green through any change in the library itself. It is now also tested against
|
||||
the real one.
|
||||
|
||||
### Added — an install that collects statistics can show the total on its landing page
|
||||
|
||||
Where install statistics are being collected, the landing page can show how many screens have been
|
||||
deployed in total. It is an aggregate across every install that chooses to report, so it says nothing
|
||||
about any single one.
|
||||
|
||||
This does nothing on a normal install: the figure is served only where collection is switched on, so a
|
||||
private server never publishes its own screen count, and the line is hidden entirely rather than
|
||||
showing a zero.
|
||||
|
||||
### Changed — release notes are the written ones
|
||||
|
||||
Published release notes now come from this file rather than from a list of commit subjects. The
|
||||
previous release announced itself as one commit titled "chore(release)" while the entry describing it
|
||||
sat here unread.
|
||||
|
||||
### ⚠️ Upgrading from 1.9.34 reinstalls dependencies
|
||||
|
||||
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in
|
||||
both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
|
||||
startup where it can reach the npm registry.
|
||||
|
||||
Docker deployments need no action; dependencies are installed inside the image.
|
||||
|
||||
## 1.9.34
|
||||
|
||||
Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what
|
||||
was there before. Alongside it: the last native image dependency is gone, several players that
|
||||
could not install updates now can, and an install can optionally report how many screens it runs.
|
||||
|
||||
No migrations and no configuration changes. See the upgrade note at the end of this entry.
|
||||
|
||||
### Fixed — the old sign-in path could be replayed by any site you had signed into
|
||||
What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and
|
||||
then trusted the email address that came back, never asking the only question that matters: *who was
|
||||
this token issued for?* Any other site a user had signed into — anything holding a token with the
|
||||
right scope — could replay it against ScreenTinker and receive a session as that user. No password,
|
||||
no interaction from the victim.
|
||||
|
||||
Identity now comes from an **ID token only**, with the signature checked against the provider's
|
||||
keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider:
|
||||
Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries
|
||||
rather than hand-written special cases, which is what removed the two paths that were wrong.
|
||||
|
||||
### Added — organizations bring their own single sign-on
|
||||
Instance-wide providers stay the default and are now unlimited in number. On top of that, an
|
||||
organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking
|
||||
OpenID Connect — configured by that organization's own admins in Settings, with no operator
|
||||
involvement and no restart.
|
||||
|
||||
A provider may only assert addresses at domains the organization has **proved it controls**, via a
|
||||
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
|
||||
releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to
|
||||
one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard
|
||||
zone we do not operate, and it would turn a subdomain takeover into an apex takeover.
|
||||
|
||||
An organization's provider never appears publicly. The login page reveals it only after someone
|
||||
enters an address at a verified domain, so a guessed domain cannot confirm who your customers are.
|
||||
|
||||
**Require single sign-on** is available per organization: passwords refused, other providers
|
||||
refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would
|
||||
just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the
|
||||
request, so one compromised org admin cannot quietly reopen password login. Break-glass for a
|
||||
platform administrator is the correct password and nothing else, and a wrong password returns the
|
||||
same refusal everyone else gets, so it cannot be used to discover whether an account exists.
|
||||
|
||||
⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible
|
||||
without a reset.
|
||||
|
||||
Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other
|
||||
grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator,
|
||||
and an organization's own provider is believed once it has verified a domain — the DNS proof stands
|
||||
in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has
|
||||
verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone.
|
||||
Other providers that verify addresses without saying so can opt in with
|
||||
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
|
||||
|
||||
**With no SSO environment variables set, the product behaves exactly as it did before.**
|
||||
|
||||
### Added — an existing account can move to single sign-on
|
||||
Signing in with a provider has always refused to take over an account that already has a password,
|
||||
and that refusal is right — otherwise anyone who could persuade a provider to assert your address
|
||||
would inherit your account. But the way out had never been built, so an account created with a
|
||||
password simply could not use single sign-on.
|
||||
|
||||
**Settings → Sign-in method** now offers it, in both directions. An account has exactly **one**
|
||||
credential: linking **deletes** the password, and the confirmation says so, because a password left
|
||||
behind is a second way in that you believe you replaced. Unlinking asks for the new password first
|
||||
and applies both changes together, so the account is never left without a way in.
|
||||
|
||||
The account being linked is the one you are **signed in as**, never whichever account matches the
|
||||
address the provider returns — that is what separates linking from the takeover the login page
|
||||
refuses. Only providers configured on this server can be linked; an organization's own provider
|
||||
cannot attach itself to an account.
|
||||
|
||||
### Changed — the login page asks who you are before how you sign in
|
||||
The password box appears once you have entered your address and continued, rather than sitting there
|
||||
from the start. That is what lets the page check whether your organization uses single sign-on
|
||||
*before* offering you a credential, so someone whose company requires it is shown that rather than a
|
||||
password box that was always going to be refused. Correcting your address takes you back a step.
|
||||
|
||||
The address is no longer looked up on every keystroke — it answered for half-finished domains,
|
||||
changed the form under you mid-address, and could exhaust a shared office network's lookup budget
|
||||
before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so
|
||||
the page no longer changes shape while you type.
|
||||
|
||||
Setup instructions for both operators and organization admins are in
|
||||
[docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications
|
||||
— including the one that catches everyone: the Microsoft tenant setting names the directory that
|
||||
*authenticates the user*, which for personal accounts is not the directory the application is
|
||||
registered in.
|
||||
|
||||
### Changed — image processing no longer needs a native library
|
||||
Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and
|
||||
avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and
|
||||
`better-sqlite3` is the only native module left.
|
||||
|
||||
A native module needs a prebuilt binary matching both the platform and the Node version; when there
|
||||
isn't one the server fails at load with an error that reads like database corruption rather than a
|
||||
missing image library. That class of failure is gone from this half of the product.
|
||||
|
||||
Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif
|
||||
through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library
|
||||
in use decodes AV1 but refuses HEVC.
|
||||
|
||||
Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a
|
||||
12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks
|
||||
an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats
|
||||
and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with
|
||||
serving requests.
|
||||
|
||||
### Fixed — players that could not install an update
|
||||
Three separate faults, each able to strand a player on an old version.
|
||||
|
||||
**Updates were written to external storage.** Where that location is absent, or exists but cannot be
|
||||
written to, the download failed the instant it began — before any data arrived — and reported only
|
||||
that it had failed to download or verify. The same player could be caching content perfectly well
|
||||
throughout, because content goes to internal storage. Updates now go to the first location that
|
||||
genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to
|
||||
it* rather than by asking whether it is writable — the previous check asked, was told yes, and the
|
||||
write failed anyway.
|
||||
|
||||
**Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one
|
||||
numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved
|
||||
forward, while the server named the newer build as latest in the same reply. Numbers in version
|
||||
names are now compared as numbers. The BrightSign host package carried the same comparison and is
|
||||
fixed with it — there, a wrong answer replaces the script that starts the player.
|
||||
|
||||
**A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate
|
||||
comes from a legacy path that can return nothing. The player now reads the signature itself before
|
||||
giving up. Verification is unchanged: the certificate is still compared against the installed app,
|
||||
and anything unsigned, tampered with, or signed by a different key is still rejected.
|
||||
|
||||
A failed update now also says which of those things went wrong, instead of one message covering
|
||||
every possible cause.
|
||||
|
||||
⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how
|
||||
updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by
|
||||
hand, after which it recovers on its own and stays fixed.
|
||||
|
||||
### Fixed — the Android player could leave a band down one edge of the screen
|
||||
A panel would sometimes not fill its display: content rendered into a stage sized from a window that
|
||||
no longer existed, leaving a bar the exact size of the hidden system bar. It was intermittent
|
||||
because it depended on whether the app was measured before or after the system UI was hidden — the
|
||||
same screen could come up correct after a reboot and wrong after an app restart.
|
||||
|
||||
The stage is now measured from the current window and re-measured when focus changes, so a late
|
||||
change to the usable area is picked up instead of being baked in at startup.
|
||||
A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar.
|
||||
It was intermittent because it depended on whether the app was measured before or after the system
|
||||
UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart.
|
||||
The stage is now measured from the current window and re-measured when focus changes.
|
||||
|
||||
Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem;
|
||||
pinning the output resolution fixed the corruption, and this fixes the band that remained.
|
||||
|
||||
### Added — opt-in install statistics
|
||||
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
|
||||
design and should stay that way. A platform administrator is asked, once, whether this install will
|
||||
share how many screens it runs.
|
||||
|
||||
The whole payload is three fields — a random instance ID, the version, and the screen count — and
|
||||
nothing else: no hostnames, addresses, organization or user names, device names, content or
|
||||
configuration. Settings shows the **actual payload this server would send**, generated live from its
|
||||
own data, alongside what it last really sent and when, so the claim can be checked rather than taken
|
||||
on trust. Turning it on reports immediately, and a blocked outbound connection is named along with
|
||||
the address to allow, rather than failing silently.
|
||||
|
||||
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
|
||||
return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you
|
||||
run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who
|
||||
wants their own numbers and nothing sent to us can set it and leave sharing off.
|
||||
|
||||
The random ID exists only so repeat reports from one server count as one server, which makes a
|
||||
report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in,
|
||||
any total published from it is a floor, never an estimate of the install base. Full detail in
|
||||
[docs/telemetry.md](docs/telemetry.md).
|
||||
|
||||
### Added — organizations may re-enable same-origin widgets, deliberately
|
||||
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce
|
||||
strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a
|
||||
typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or
|
||||
admin — a workspace admin is deliberately not enough — and the change is written to the activity
|
||||
log. Contributed by @ChrisChrome.
|
||||
|
||||
The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard
|
||||
where the admin's session token lives, so honouring the setting there would let anyone who can
|
||||
author a widget lift the session of whichever admin clicked Preview. The setting exists so
|
||||
*displays* can embed origin-strict sites; a display holds a device token, an admin's browser does
|
||||
not.
|
||||
|
||||
### Fixed — RSS tickers ran at a speed that depended on how much news there was
|
||||
Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so
|
||||
a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read,
|
||||
and it appeared to jump back to the start. It now holds a constant rate, so more items simply take
|
||||
proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
|
||||
|
||||
### Fixed — user-controlled text is escaped where it reaches the page
|
||||
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
|
||||
Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when
|
||||
switching views, and the main content no longer collapses to a narrow column.
|
||||
|
||||
### Added — an operations runbook
|
||||
`docs/operations.md`. How to deploy, verify, and roll back an instance in both shapes it runs in
|
||||
(git + systemd, and Docker), including what to back up first, how to tell a deploy actually worked,
|
||||
and the traps that are only obvious once they have bitten you.
|
||||
|
||||
### Changed — image processing no longer uses a native module
|
||||
`sharp` is gone. Thumbnailing and image measurement are pure JavaScript (Jimp) with WebAssembly
|
||||
codecs for webp and avif, running on a worker thread. `sharp` remains as a development dependency
|
||||
for test fixtures, so `--omit=dev` excludes it from a deployed install entirely.
|
||||
|
||||
The motivation is that a native module needs a prebuilt binary matching both the platform and the
|
||||
Node ABI; when there isn't one, the failure arrives at load time and reads like database corruption
|
||||
rather than a missing image library. `better-sqlite3` is now the only native module left.
|
||||
|
||||
Format support is unchanged in practice. jpeg, png, gif, tiff and bmp decode natively; webp and avif
|
||||
via WebAssembly. `.heic` still produces no thumbnail — it never did, because the `sharp` builds in
|
||||
use decode AV1 but refuse HEVC.
|
||||
|
||||
Images are decoded on a worker thread rather than in-process. Pure-JavaScript decoding costs about a
|
||||
second for a 12-megapixel photo, which in-process would block the event loop — and the thumbnail
|
||||
backfill walks an entire library at boot, which is exactly how a maintenance task turns into missed
|
||||
heartbeats and players marked offline. Thumbnailing is slower in wall-clock terms than the native
|
||||
library was, and no longer competes with serving requests.
|
||||
[docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both
|
||||
shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only
|
||||
obvious once they have bitten you — including three from a Raspberry Pi 5 report, two of which are
|
||||
not Pi-specific. A piped installer cannot really ask you anything, because the pipe is its input and
|
||||
every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor
|
||||
hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD
|
||||
card by discarding writes — safe for a player, quietly destructive for a server whose database is
|
||||
written continuously.
|
||||
|
||||
### Changed — `better-sqlite3` pinned to 12.9.0
|
||||
Preparation for a future Node 22 upgrade, landed separately so the runtime move and the database
|
||||
driver move stay independently reversible.
|
||||
Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database
|
||||
driver can move independently rather than as one flag day.
|
||||
|
||||
The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both
|
||||
the current and the next Node major; later 12.x releases dropped the older one while still
|
||||
advertising support for it in `engines`. A caret range would resolve to one of those and silently
|
||||
turn installation into a from-source compile. `lib/preflight-deps.js` explains this at the point
|
||||
anyone debugging the resulting failure would be reading.
|
||||
advertising support for it. A caret range would resolve to one of those and silently turn
|
||||
installation into a source build. Nothing in the query API changed.
|
||||
|
||||
Nothing in the query API changed — every major since 9.x was bumped only to drop end-of-life Node
|
||||
and Electron versions.
|
||||
### ⚠️ Upgrading from 1.9.33 reinstalls dependencies
|
||||
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** —
|
||||
in both directions.
|
||||
|
||||
## 1.9.34-alpha6
|
||||
- **Upgrading**: `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
|
||||
startup where it can reach the npm registry.
|
||||
- **Rolling back past this release**: mandatory. Earlier builds load a native image library at
|
||||
runtime that this release removes, so rolling back the code without reinstalling leaves a server
|
||||
whose image ingest cannot load its decoder.
|
||||
|
||||
### Added — a setup guide for single sign-on
|
||||
`docs/sso-setup.md`. The README explained what SSO is and listed the settings; it did not say where
|
||||
to click, which of several plausible values to use, or what a given failure means. The guide walks
|
||||
both the operator configuring Google or Microsoft for the whole instance and an organization admin
|
||||
bringing their own provider, and ends with a table of every error the sign-in can produce alongside
|
||||
what usually causes it.
|
||||
Docker deployments need no action either way; dependencies are installed inside the image.
|
||||
|
||||
Written from configuring real Google and Entra applications rather than from the code, so the
|
||||
pitfalls in it are the ones that actually cost time — most of all that the Microsoft tenant setting
|
||||
names the directory that *authenticates the user*, which for personal accounts is not the directory
|
||||
the application is registered in.
|
||||
### Known limitations
|
||||
Deliberately unresolved, and worth knowing:
|
||||
|
||||
## 1.9.34-alpha5
|
||||
|
||||
### Fixed — the Link button in Settings always said "Authentication required"
|
||||
`alpha4` shipped account linking with a button that could never work. It navigated the browser
|
||||
straight at the link endpoint, and this app's session is a token held in the page rather than a
|
||||
cookie — so the request arrived with no credentials and was refused, every time.
|
||||
|
||||
The page now asks the server for the sign-in URL first, using its session, and follows that. Nothing
|
||||
about the link itself changed.
|
||||
|
||||
## 1.9.34-alpha4
|
||||
|
||||
Two changes to how signing in works, both found by configuring real Google and Microsoft sign-in
|
||||
rather than by reading the code.
|
||||
|
||||
### Added — an existing account can move to single sign-on
|
||||
Signing in with a provider has always refused to take over an account that already has a password.
|
||||
That refusal is right: otherwise anyone who could get a provider to assert your address would inherit
|
||||
your account. But the way out — sign in with your password, then link from Settings — had never been
|
||||
built, so it was a dead end. An account created with a password simply could not use single sign-on.
|
||||
|
||||
**Settings → Sign-in method** now offers it. An account with a password can link one of the
|
||||
providers this server offers; an account on a provider can unlink back to a password.
|
||||
|
||||
An account has exactly **one** credential. Linking **deletes** the password, and the confirmation
|
||||
says so plainly, because a password left behind is a second way in that you believe you replaced.
|
||||
Unlinking asks for the new password first and applies both changes together, so the account is never
|
||||
left, even briefly, with no way to sign in.
|
||||
|
||||
The account being linked is the one you are **signed in as** — never whichever account matches the
|
||||
email the provider returns. That is what separates linking from the takeover the login page refuses.
|
||||
Only the providers configured on this server can be linked; an organization's own provider cannot
|
||||
attach itself to an account.
|
||||
|
||||
### Changed — the login page asks who you are before how you sign in
|
||||
The password box now appears once you have entered your email address and continued, rather than
|
||||
sitting there from the start. That is what lets the page ask whether your organization uses single
|
||||
sign-on *before* offering you a credential: if it does, you are shown that instead of a password box
|
||||
that was going to be refused. Correcting your address takes you back a step so the answer matches
|
||||
what you actually typed.
|
||||
|
||||
The address is no longer looked up on every keystroke. It answered for half-finished domains, changed
|
||||
the form under you mid-address, and could exhaust a shared office IP's lookup budget before anyone
|
||||
had tried to sign in.
|
||||
|
||||
Google and Microsoft buttons now stay visible throughout, including for organizations that require
|
||||
their own provider. Such a login is still refused by the server; the page no longer changes shape
|
||||
while you type.
|
||||
|
||||
## 1.9.34-alpha3
|
||||
|
||||
Completes what `alpha2` half-fixed. That release made the operator's own Microsoft button work and
|
||||
left customers' Entra tenants broken, which is the wrong way round.
|
||||
|
||||
### Fixed — a customer's own Entra tenant was refused after its domain went green
|
||||
An organization that brings its own Microsoft tenant publishes the DNS record, watches its domain
|
||||
verify, and was then refused at login with `email_unverified`. Entra sends no such claim, and an
|
||||
organization's provider was never allowed to assume one.
|
||||
|
||||
The refusal fired *after* domain confinement had already passed, so it was not the domain check doing
|
||||
its job — it was a second check asking for something Microsoft does not emit. Requiring a claim a
|
||||
provider cannot send is not a security control; it is an outage.
|
||||
|
||||
An organization's provider is now believed **once it has verified a domain**. The DNS proof is what
|
||||
stands in for the claim: whoever controls a domain's DNS controls its mail, which is the same trust
|
||||
that makes a verification link meaningful. A provider that has verified nothing still assumes
|
||||
nothing, domain confinement is unchanged, and an explicit `email_verified: false` is still refused
|
||||
from anyone.
|
||||
|
||||
The assumption follows from the proof and is never a stored setting — an organization cannot switch
|
||||
it on for itself.
|
||||
|
||||
## 1.9.34-alpha2
|
||||
|
||||
Everything in `1.9.34-alpha1`, plus one fix without which the headline feature could not be used with
|
||||
Microsoft at all.
|
||||
|
||||
### Fixed — Microsoft sign-in could never complete
|
||||
The login callback required the identity provider to assert `email_verified: true`. **Entra ID v2
|
||||
does not send that claim**, so a Microsoft login would authenticate correctly against the tenant and
|
||||
then be refused on the way back with `email_unverified`. Found while configuring a real Entra
|
||||
application, before a single sign-in was attempted; the SSO tests checked how the Microsoft issuer
|
||||
string is built but never put a Microsoft-shaped token through the policy.
|
||||
|
||||
The strict check was itself a fix — an earlier version accepted an omitted claim — and it stays
|
||||
exactly as strict for a provider a **customer** configures, because such a provider is chosen by the
|
||||
party it vouches for and its bare assertion is worth nothing. What changed is recognising this as a
|
||||
question about *who was trusted* rather than about what the token contained: an instance-wide
|
||||
provider is chosen by the operator, and a Microsoft entry is pinned to a single tenant GUID, so only
|
||||
that directory can issue a token this server will accept.
|
||||
|
||||
An explicit `email_verified: false` is still refused from anyone, and an organization's own provider
|
||||
can never make the assumption. Google is unchanged — it does send the claim.
|
||||
|
||||
Other identity providers that verify addresses without saying so in the token can opt in with
|
||||
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
|
||||
|
||||
### Fixed — documentation that would have cost you an afternoon
|
||||
`MICROSOFT_CLIENT_SECRET` is read by the server but was missing from the README table. The redirect
|
||||
URI must be registered under Entra's **Web** platform, not **SPA** — a SPA registration is rejected
|
||||
at the token endpoint, because this exchange runs server-side and sends no browser `Origin`. And the
|
||||
`email` optional claim has to be added under *Token configuration → ID*, or the token arrives with no
|
||||
address at all.
|
||||
|
||||
## 1.9.34-alpha1
|
||||
|
||||
**A prerelease, for the alpha instance.** It is not a production build and no display should be
|
||||
pulled onto it except deliberately: a prerelease sorts *below* its own release in semver, so a player
|
||||
that takes `1.9.34-alpha1` without being opted in will see `1.9.33` as newer and roll itself back.
|
||||
Opting a display in is what stops that.
|
||||
|
||||
The headline is single sign-on, rebuilt from nothing — and the reason it was rebuilt rather than
|
||||
extended is a vulnerability in what was there before.
|
||||
|
||||
### Fixed — the old sign-in path could be replayed by any site you had signed into
|
||||
What shipped as "OAuth" verified almost nothing. The Google path asked `tokeninfo` whether an
|
||||
**access** token was valid and then trusted the email address in the reply. The Microsoft path handed
|
||||
a bearer token to Graph `/me` and trusted that. Neither asked the only question that matters: *who
|
||||
was this token issued for?*
|
||||
|
||||
So any other site a user had signed into — anything that had requested `email` or `User.Read` — held
|
||||
a token it could replay against ScreenTinker and receive a session as that user. No password, no
|
||||
interaction from the victim.
|
||||
|
||||
Identity now comes from an **ID token only**, with signature checked against the provider's JWKS and
|
||||
`iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider: Authorization Code
|
||||
with PKCE, completed server-side. Google and Microsoft became ordinary entries rather than special
|
||||
cases, which is what removed the two hand-written paths that were wrong.
|
||||
|
||||
### Added — organizations bring their own identity provider
|
||||
Instance-wide providers stay the default and are now unlimited in number. On top of that an
|
||||
organization may configure its own provider, but only for domains it has **proved it controls** — a
|
||||
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
|
||||
releases the domain, so a typo cannot park someone else's domain indefinitely.
|
||||
|
||||
Proof by delegated name (CNAME) is refused outright. It would have required a wildcard zone we do not
|
||||
operate, and worse, it would turn a subdomain takeover into an apex takeover.
|
||||
|
||||
**SSO-only** is available per organization: passwords refused, other providers refused, the instance
|
||||
Google button refused. Turning it *off* again needs a platform admin to approve the request, so one
|
||||
compromised org admin cannot quietly reopen password login. Break-glass for a platform admin is the
|
||||
correct password and nothing else — and a wrong password returns the same 403 everyone else gets, so
|
||||
it cannot be used to discover whether an account exists.
|
||||
|
||||
With no SSO environment variables set, the product behaves exactly as it did before. That was
|
||||
verified in a browser, not merely reasoned about.
|
||||
|
||||
### Added — organizations may re-enable same-origin widgets, deliberately
|
||||
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce strict
|
||||
CORS. There is now an org-level switch to put it back, behind a modal that requires the operator to
|
||||
type an acknowledgement, with a persistent banner while it is on. Enabling it needs an organization
|
||||
owner or admin — a workspace admin is deliberately not enough — and the change is written to the
|
||||
activity log. Contributed by @ChrisChrome.
|
||||
|
||||
The **widget editor's Preview is excluded** from that switch. Preview renders inside the dashboard,
|
||||
where the admin's session token lives, so honouring the setting there would have let anyone who can
|
||||
author a widget lift the session of whichever admin clicked Preview. The setting exists so *displays*
|
||||
can embed origin-strict sites; a display holds a device token, an admin's browser does not.
|
||||
|
||||
### Fixed — RSS tickers ran at a speed that depended on how much news there was
|
||||
`scroll_speed` was wired straight into `animation-duration`, so it set a fixed total time for the
|
||||
whole strip to cross the screen regardless of length. A feed with twenty items was dragged past in
|
||||
the same seconds as a feed with one — too fast to read, and it appeared to jump back to the start.
|
||||
It now calibrates a constant pixels-per-second rate, so more items simply take proportionally longer
|
||||
and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
|
||||
|
||||
### Fixed — user-controlled text is escaped where it actually reaches HTML
|
||||
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
|
||||
Also in this release: dashboard banners no longer overlap the sidebar, shift the layout or vanish
|
||||
when switching views, and the main content no longer collapses to a narrow column.
|
||||
|
||||
### Known limitations in this alpha
|
||||
Deliberately not resolved yet, and worth knowing before testing against them:
|
||||
|
||||
- Enabling SSO-only **clears the passwords** of members at verified domains. That is irreversible
|
||||
- Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly
|
||||
without a reset.
|
||||
- The SSO-only removal queue is an availability dependency on the operator: if nobody approves, the
|
||||
organization stays SSO-only.
|
||||
- Turning that requirement back off depends on a platform administrator approving the request; if
|
||||
nobody does, the organization stays on single sign-on.
|
||||
- `landing.html` still interpolates plan names into HTML without escaping. Those values come from
|
||||
the plans table rather than from end users, so it is a loose end rather than an exposure.
|
||||
- `/api/provision` is limited to 5/min, so a twenty-display install day takes four minutes of waiting.
|
||||
Pre-existing, unchanged by this release.
|
||||
- `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves
|
||||
some waiting. Pre-existing and unchanged by this release.
|
||||
|
||||
### Thanks
|
||||
This release — and a good deal of what came before it — exists because people outside the project
|
||||
reported problems and sent patches. Credit was recorded inconsistently at the time, so it is
|
||||
collected here rather than left scattered.
|
||||
|
||||
**Code contributed**
|
||||
|
||||
- **@ChrisChrome** — the organization-level widget sandbox toggle (#254) and the RSS ticker rate fix,
|
||||
both in this release. Earlier: the Debian player/server install script (#137) and web player
|
||||
auto-connect (#6).
|
||||
- **@BlazzzPlay** — eight merged pull requests across 1.9.4 to 1.9.13: server-side preview sessions
|
||||
to work around CSP (#151), the Android hidden settings menu (#152), sending device identity on
|
||||
reconnect before pairing (#164), the dashboard version indicator and update check (#165, #181),
|
||||
authenticated thumbnail loading (#182), the server URL in the Add Display modal and the Releases
|
||||
link on the APK download page (#210), and uploads respecting the current folder (#211).
|
||||
- **@a10kiloham** — boot-time thumbnail healing with ffmpeg diagnostics and packaging (#244), the
|
||||
screenshot-request verdict toast and the reverse-proxy header pitfall it documented (#243), and a
|
||||
configurable maximum upload size (#233).
|
||||
- **@albanobattistella** — the Italian translation, and its updates since (#2, #145, #232).
|
||||
|
||||
**Reported**
|
||||
|
||||
- **@carloblu74** — the Raspberry Pi 5 report behind #245, which found five defects in the installer
|
||||
and kiosk launcher that nothing in this repository would have caught, because nothing here had ever
|
||||
executed those scripts on a Pi. The runbook notes above come from it.
|
||||
- **@bold-media-group** — by a wide margin the largest source of field reports, across roughly fifty
|
||||
issues: the OTA rollout and version-advertising problems, event-loop lag under long uptime, video
|
||||
wall behaviour, Tizen playback regressions, and the content-loading failures that led to resumable
|
||||
downloads.
|
||||
- **@Smiley-k**, **@Semetra22**, **@patrickfinardi09**, **@hapishyguy**, **@Nikhil12656**,
|
||||
**@gittyguy92** and **@Obe-BoldMediaGroup** — bug reports and feature requests across the 1.9.x
|
||||
line, including SMTP transport, playlist item scheduling, and the Android playlist-order fault
|
||||
behind #234.
|
||||
|
||||
Several of the hardest faults this year were found by someone running the product on hardware the
|
||||
project does not own. That is worth saying plainly.
|
||||
|
||||
## 1.9.33
|
||||
|
||||
|
|
|
|||
|
|
@ -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? ?: "113").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.34-alpha7"
|
||||
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "123").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.36"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
@ -87,8 +87,24 @@ dependencies {
|
|||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
|
||||
// Socket.IO client
|
||||
implementation("io.socket:socket.io-client:2.1.0")
|
||||
// Socket.IO client.
|
||||
//
|
||||
// org.json is excluded deliberately. socket.io-client pulls org.json:json:20090211
|
||||
// transitively, and that artifact was being packaged into the APK in full — 19 classes,
|
||||
// including CDL, XML, JSONML and its own Test class. It carries the JSON License, whose
|
||||
// "shall be used for Good, not Evil" clause is not OSI-approved, is treated as non-free by
|
||||
// Debian and Fedora, and is Category X at Apache. Shipping it in a commercially distributed
|
||||
// binary is an avoidable licensing problem: it is not copyleft, but it is not a licence we
|
||||
// want to have to explain.
|
||||
//
|
||||
// Nothing is lost. Android provides org.json in the platform (since API 1, and minSdk is 24),
|
||||
// and the only classes either side actually touches are JSONObject, JSONArray and JSONTokener.
|
||||
// The full method surface used — by socket.io/engine.io and by our own Kotlin — is
|
||||
// get/getString/getLong/getJSONArray/getJSONObject/has/keys/length/isNull/put/NULL,
|
||||
// the opt* family, and JSONTokener.nextValue. Every one is platform API.
|
||||
implementation("io.socket:socket.io-client:2.1.0") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
|
||||
// WorkManager for background downloads
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
|
|
|
|||
|
|
@ -941,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) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,18 @@ class UpdateChecker(private val context: Context) {
|
|||
// class is the imperative shell that persists state and does the download/install.
|
||||
var otaLogReporter: ((level: String, message: String) -> Unit)? = null
|
||||
|
||||
/*
|
||||
* Why the last download/verify attempt failed, in specific terms.
|
||||
*
|
||||
* The caller could only ever say "failed to download or failed signature verification", which
|
||||
* covers SEVEN distinct branches — three of them download failures where verification never
|
||||
* runs at all. Every specific reason went to logcat, which an unprivileged app UID cannot read
|
||||
* on Android 9, so in the field the message was unactionable: it named a symptom shared by
|
||||
* unrelated causes and pointed at the wrong half of the code as often as the right one.
|
||||
* Diagnosing one occurrence took an evening. This makes the next one a sentence.
|
||||
*/
|
||||
private var lastFailure: String? = null
|
||||
|
||||
private fun report(level: String, message: String) {
|
||||
when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) }
|
||||
try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {}
|
||||
|
|
@ -272,7 +284,7 @@ class UpdateChecker(private val context: Context) {
|
|||
// Unforced this is deliberately quiet (transient network blips are not news). Forced,
|
||||
// somebody is waiting on an answer, and "the APK would not download or did not match
|
||||
// our signing key" is the single most useful thing we can tell them.
|
||||
if (forced) report("error", "Force update: $latestVersion failed to download or failed signature verification — not installed")
|
||||
if (forced) report("error", "Force update: $latestVersion not installed — ${lastFailure ?: "reason unavailable"}")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -311,15 +323,99 @@ class UpdateChecker(private val context: Context) {
|
|||
// Returns TRUE only when a verified APK is in hand and an install has been launched (the
|
||||
// caller may then count an attempt); FALSE on any download/verify failure — the caller must
|
||||
// NOT count those, so a transient network problem can't burn a healthy device's budget. #139
|
||||
/*
|
||||
* Where a downloaded APK is staged.
|
||||
*
|
||||
* getExternalFilesDir() returns NULL whenever external storage is not mounted/available — and
|
||||
* on a signage panel that is not exotic: no emulated volume, a vendor ROM that never mounts one,
|
||||
* an SD card ejected, storage still unmounted early in boot.
|
||||
*
|
||||
* The bug this replaces: `File(context.getExternalFilesDir(...), name)`. Java's File(File,String)
|
||||
* treats a NULL parent as "no parent" and silently produces a RELATIVE path, so the download
|
||||
* targeted `ScreenTinker-x.y.z.apk` in the process working directory — `/` — which is not
|
||||
* writable. The write threw, the generic catch swallowed it, and the caller reported only
|
||||
* "failed to download or failed signature verification". Nothing was ever written, so there was
|
||||
* no partial file to find and nothing in the message pointed at storage. It fails on EVERY
|
||||
* attempt, forever, on an affected panel — and identically for the pushed-APK path, which had
|
||||
* the same line.
|
||||
*
|
||||
* Internal storage always exists, so fall back to it. It costs nothing when external is present.
|
||||
* NOTE: the intent-based install fallback resolves this file through FileProvider, so
|
||||
* res/xml/file_paths.xml must expose this directory too — see the <files-path> entry there.
|
||||
*/
|
||||
/*
|
||||
* Where to stage a downloaded APK — the FIRST location that actually accepts bytes.
|
||||
*
|
||||
* Internal app storage is tried first and is effectively guaranteed: /data/data/<pkg>/files is
|
||||
* this app's own private directory, always mounted, always writable. If it is not, the app is
|
||||
* not running. External storage is only a convenience (it survives uninstall and is visible for
|
||||
* a manual install), and it is the one that fails — it can be absent, unmounted, present but
|
||||
* unwritable, or report canWrite() = true and then refuse the write anyway.
|
||||
*
|
||||
* ⚠️ Each candidate is PROVEN with a real write, not asked. The previous version asked
|
||||
* canWrite(), believed the answer, and then died at outputStream() — before a single byte — so
|
||||
* the update failed instantly and reported it as a download problem. Every fallback in the world
|
||||
* is useless if the first choice is trusted rather than tested.
|
||||
*
|
||||
* Returns the directory, or null with every reason it could not find one, so the operator gets
|
||||
* the full picture instead of the first excuse.
|
||||
*/
|
||||
private fun apkStagingDir(needBytes: Long): Pair<File?, String> {
|
||||
val candidates = LinkedHashMap<String, File>()
|
||||
candidates["internal"] = File(context.filesDir, "Download")
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.let { candidates["external"] = it }
|
||||
candidates["cache"] = File(context.cacheDir, "Download")
|
||||
candidates["files"] = context.filesDir // last resort: no subdirectory to create
|
||||
|
||||
val reasons = StringBuilder()
|
||||
for ((name, dir) in candidates) {
|
||||
val problem = apkDirProblem(dir, needBytes)
|
||||
if (problem == null) {
|
||||
if (name != "internal") Log.w(TAG, "Staging APK in $name (${dir.absolutePath})")
|
||||
return dir to name
|
||||
}
|
||||
if (reasons.isNotEmpty()) reasons.append("; ")
|
||||
reasons.append("$name ${problem}")
|
||||
}
|
||||
return null to reasons.toString()
|
||||
}
|
||||
|
||||
private fun apkDirProblem(dir: File, needBytes: Long): String? {
|
||||
if (!dir.exists() && !dir.mkdirs()) return "cannot create ${dir.absolutePath}"
|
||||
if (!dir.isDirectory) return "${dir.absolutePath} is not a directory"
|
||||
if (!dir.canWrite()) return "no write permission on ${dir.absolutePath}"
|
||||
val free = try { dir.usableSpace } catch (_: Throwable) { -1L }
|
||||
// Headroom, not an exact fit: the installer stages its own copy of the APK as well, so a
|
||||
// volume with barely the download's worth free still fails at install time.
|
||||
if (needBytes > 0 && free in 0 until (needBytes * 2)) {
|
||||
return "only ${free / 1024 / 1024}MB free on ${dir.absolutePath}, need ~${needBytes * 2 / 1024 / 1024}MB"
|
||||
}
|
||||
// Prove it rather than infer it: canWrite() can be true on a volume that refuses the write.
|
||||
return try {
|
||||
val probe = File(dir, ".st-write-probe")
|
||||
probe.writeBytes(byteArrayOf(1))
|
||||
probe.delete()
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
"write test failed in ${dir.absolutePath}: ${e.javaClass.simpleName} ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAndInstall(url: String, version: String): Boolean {
|
||||
try {
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
"ScreenTinker-$version.apk")
|
||||
// Find somewhere that will actually take the file, before asking the network for it.
|
||||
val (dir, whereOrWhy) = apkStagingDir(9L * 1024 * 1024)
|
||||
if (dir == null) {
|
||||
lastFailure = "nowhere to stage the update — $whereOrWhy"
|
||||
Log.e(TAG, "APK staging unavailable: $whereOrWhy")
|
||||
return false
|
||||
}
|
||||
val apkFile = File(dir, "ScreenTinker-$version.apk")
|
||||
|
||||
// #139: reuse a previously-downloaded, verified APK for this version instead of
|
||||
// re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a
|
||||
// manual install when silent install isn't possible.
|
||||
if (apkFile.exists() && verifyApkSignature(apkFile)) {
|
||||
if (apkFile.exists() && cachedApkIs(apkFile, version) && verifyApkSignature(apkFile)) {
|
||||
Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
|
||||
handler.post { installApk(apkFile) }
|
||||
return true
|
||||
|
|
@ -332,6 +428,7 @@ class UpdateChecker(private val context: Context) {
|
|||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
lastFailure = "server returned HTTP ${response.code} for the APK"
|
||||
Log.e(TAG, "Download failed: ${response.code}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -351,7 +448,21 @@ class UpdateChecker(private val context: Context) {
|
|||
// Verify the downloaded APK is our package AND signed by the same key as
|
||||
// the currently-installed app before installing. An attacker can't forge
|
||||
// our signature, so this holds even over an untrusted transport.
|
||||
// The server advertises a version and separately serves a file; the two can drift. A
|
||||
// stale APK behind a current version number installs as a NO-OP, so the version never
|
||||
// changes, the update is attempted again, and the panel loops until its attempts are
|
||||
// spent — reporting a download failure, which it is not. Say what actually happened.
|
||||
if (!cachedApkIs(apkFile, version)) {
|
||||
val got = apkVersionName(apkFile) ?: "unreadable"
|
||||
lastFailure = "server served $got but advertised $version — the update on the server is stale"
|
||||
Log.e(TAG, "Version mismatch: advertised $version, downloaded $got")
|
||||
apkFile.delete()
|
||||
return false
|
||||
}
|
||||
if (!verifyApkSignature(apkFile)) {
|
||||
// lastFailure was set precisely inside verifyApkSignature; keep it, and add the
|
||||
// size so a truncated download is distinguishable from a genuine cert mismatch.
|
||||
lastFailure = "${lastFailure ?: "signature verification failed"} (downloaded ${apkFile.length()} bytes)"
|
||||
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
|
||||
apkFile.delete()
|
||||
return false
|
||||
|
|
@ -364,6 +475,7 @@ class UpdateChecker(private val context: Context) {
|
|||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
lastFailure = "download/install threw ${e.javaClass.simpleName}: ${e.message}"
|
||||
Log.e(TAG, "Download/install error: ${e.message}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -378,7 +490,9 @@ class UpdateChecker(private val context: Context) {
|
|||
try {
|
||||
val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" }
|
||||
val fileName = "pushed-" + (if (base.endsWith(".apk")) base else "$base.apk")
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
|
||||
val (dir, whyNot) = apkStagingDir(9L * 1024 * 1024)
|
||||
if (dir == null) { Log.e(TAG, "installFromUrl: nowhere to stage — $whyNot"); return@Thread }
|
||||
val apkFile = File(dir, fileName)
|
||||
if (apkFile.exists()) apkFile.delete()
|
||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
if (!response.isSuccessful) { Log.e(TAG, "installFromUrl: download failed ${response.code}"); return@Thread }
|
||||
|
|
@ -482,6 +596,52 @@ class UpdateChecker(private val context: Context) {
|
|||
|
||||
// True only if the downloaded APK is this same package and shares a signing
|
||||
// certificate with the installed app. Fail-closed on any error.
|
||||
/* The versionName inside an APK file, or null if it cannot be read. */
|
||||
private fun apkVersionName(apkFile: File): String? = try {
|
||||
context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)?.versionName
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Could not read version from ${apkFile.name}: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
/*
|
||||
* Is this file actually the version we mean to install?
|
||||
*
|
||||
* The cache is keyed by FILENAME, and the filename is built from the version the server
|
||||
* advertised — so a file called ScreenTinker-1.9.34.apk containing 1.9.33 passes a signature
|
||||
* check (same key), gets reused on every attempt, and installs as a no-op forever. Fixing the
|
||||
* server does not clear it; only deleting the file does. Checking the version inside makes that
|
||||
* self-healing instead of needing a hand on the device.
|
||||
*/
|
||||
private fun cachedApkIs(apkFile: File, version: String): Boolean {
|
||||
val got = apkVersionName(apkFile) ?: return false
|
||||
if (got == version) return true
|
||||
Log.w(TAG, "Cached ${apkFile.name} contains $got, expected $version — discarding")
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Delete every staged APK. The escape hatch for a panel holding a bad download: it forces the
|
||||
* next check to fetch again rather than reuse. Safe at any time — these are only ever caches,
|
||||
* re-fetched on demand.
|
||||
*/
|
||||
fun clearUpdateCache(): Int {
|
||||
var n = 0
|
||||
for (dir in listOfNotNull(
|
||||
File(context.filesDir, "Download"),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
File(context.cacheDir, "Download"),
|
||||
)) {
|
||||
val files = try { dir.listFiles() } catch (_: Throwable) { null } ?: continue
|
||||
for (f in files) {
|
||||
if (!f.name.endsWith(".apk")) continue
|
||||
if (f.delete()) n++
|
||||
}
|
||||
}
|
||||
report("info", "Update cache cleared ($n file(s)) — the next check will download afresh")
|
||||
return n
|
||||
}
|
||||
|
||||
private fun verifyApkSignature(apkFile: File): Boolean {
|
||||
return try {
|
||||
val pm = context.packageManager
|
||||
|
|
@ -498,10 +658,12 @@ class UpdateChecker(private val context: Context) {
|
|||
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags)
|
||||
if (downloaded == null) {
|
||||
lastFailure = "the downloaded file could not be parsed as an APK (truncated or not an APK)"
|
||||
Log.e(TAG, "Could not parse downloaded APK")
|
||||
return false
|
||||
}
|
||||
if (downloaded.packageName != context.packageName) {
|
||||
lastFailure = "APK is package ${downloaded.packageName}, expected ${context.packageName}"
|
||||
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -511,18 +673,37 @@ class UpdateChecker(private val context: Context) {
|
|||
val installedFlags = if (installedUsesSigningInfo)
|
||||
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
val installed = pm.getPackageInfo(context.packageName, installedFlags)
|
||||
val downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
|
||||
var downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
|
||||
// #139 follow-up: on API 28/29 the archive read goes through the legacy GET_SIGNATURES
|
||||
// path, and if PackageManager hands back nothing we previously refused a perfectly good
|
||||
// APK with no way to tell that apart from a real mismatch. Read the v1 signature
|
||||
// ourselves before giving up — JarFile is random-access, which is how the JAR signature
|
||||
// is meant to be read, and it verifies the same bytes PackageManager would have.
|
||||
// This does NOT weaken the check: the cert extracted here is still compared against the
|
||||
// installed app's below, and an unsigned or differently-signed APK still fails.
|
||||
if (downloadedSigs.isEmpty()) {
|
||||
val viaJar = archiveCertsViaJar(apkFile)
|
||||
if (viaJar.isNotEmpty()) {
|
||||
Log.w(TAG, "Archive certs unreadable via PackageManager on API ${Build.VERSION.SDK_INT}; used JarFile (${viaJar.size})")
|
||||
downloadedSigs = viaJar
|
||||
}
|
||||
}
|
||||
val installedSigs = signingCertHashes(installed, installedUsesSigningInfo)
|
||||
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
|
||||
lastFailure = "could not read signing certificates (archive=${downloadedSigs.size}, installed=${installedSigs.size}) on API ${Build.VERSION.SDK_INT}"
|
||||
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
|
||||
return false
|
||||
}
|
||||
// Require a non-empty overlap of signer certs (handles multi-signer / cert-rotation
|
||||
// the same way the API>=30 path does: compare the full current signer sets).
|
||||
val match = downloadedSigs.any { it in installedSigs }
|
||||
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
|
||||
if (!match) {
|
||||
lastFailure = "APK is signed by a different key than the installed app"
|
||||
Log.e(TAG, "APK signing certificate does not match installed app")
|
||||
}
|
||||
match
|
||||
} catch (e: Exception) {
|
||||
lastFailure = "signature check threw ${e.javaClass.simpleName}: ${e.message}"
|
||||
Log.e(TAG, "Signature verification error: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
|
|
@ -533,6 +714,31 @@ class UpdateChecker(private val context: Context) {
|
|||
// multi-signer + rotation aware), GET_SIGNATURES -> legacy .signatures (the only field
|
||||
// populated for ARCHIVE reads on API 28/29). Both yield the same cert for a normally-signed
|
||||
// APK; the caller compares as sets so an overlapping signer still verifies.
|
||||
/*
|
||||
* Read the APK's v1 (JAR) signer certificates directly, as a fallback for the API 28/29 archive
|
||||
* read. Opening JarFile with verify=true and reading an entry to completion is what populates
|
||||
* JarEntry.certificates — the certificate is only known once the bytes it covers have been
|
||||
* checked, so the read is the verification, not a step before it.
|
||||
*
|
||||
* Returns an empty set on any problem, which leaves the caller refusing the install: this is a
|
||||
* fallback for "PackageManager told us nothing", never a way to skip the comparison.
|
||||
*/
|
||||
private fun archiveCertsViaJar(apkFile: File): Set<String> {
|
||||
return try {
|
||||
java.util.jar.JarFile(apkFile, true).use { jar ->
|
||||
val entry = jar.getJarEntry("AndroidManifest.xml") ?: return emptySet()
|
||||
jar.getInputStream(entry).use { input ->
|
||||
val buf = ByteArray(8192)
|
||||
while (input.read(buf) != -1) { /* must read fully before certificates populate */ }
|
||||
}
|
||||
entry.certificates?.mapNotNull { sha256(it.encoded) }?.toSet() ?: emptySet()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "JarFile cert read failed: ${e.message}")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun signingCertHashes(info: PackageInfo, useSigningInfo: Boolean): Set<String> {
|
||||
val sigs: Array<Signature>? = if (useSigningInfo) {
|
||||
info.signingInfo?.apkContentsSigners
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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" }
|
||||
]
|
||||
}
|
||||
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.34
|
||||
version: 1.9.36
|
||||
description: |
|
||||
Public, token-scoped REST API for ScreenTinker digital signage.
|
||||
|
||||
|
|
|
|||
|
|
@ -332,3 +332,25 @@ an identity provider and that provider later fails, the login page cannot help y
|
|||
**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.
|
||||
|
|
|
|||
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.
|
||||
|
|
@ -266,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`),
|
||||
|
|
|
|||
|
|
@ -683,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',
|
||||
|
|
@ -773,6 +775,7 @@ export default {
|
|||
'device.toast.screen_on_sent': 'Screen on command sent',
|
||||
'device.toast.launch_sent': 'Launch command sent',
|
||||
'device.toast.update_triggered': 'Update check triggered',
|
||||
'device.toast.update_cache_cleared': 'Update cache cleared — the next check will download afresh',
|
||||
'device.toast.remote_started': 'Remote session started',
|
||||
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
|
||||
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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';
|
||||
|
|
@ -290,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">
|
||||
|
|
@ -432,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
|
||||
|
|
|
|||
|
|
@ -501,6 +501,12 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<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">
|
||||
|
|
@ -1593,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();
|
||||
|
|
|
|||
|
|
@ -169,6 +169,13 @@ export async function render(container) {
|
|||
<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">
|
||||
|
|
@ -275,6 +282,7 @@ export async function render(container) {
|
|||
if (isAdmin) {
|
||||
loadUsers();
|
||||
loadWhiteLabel();
|
||||
loadTelemetry();
|
||||
|
||||
// Support token generator
|
||||
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
|
||||
|
|
@ -547,6 +555,87 @@ export async function render(container) {
|
|||
* 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;
|
||||
|
|
|
|||
|
|
@ -210,6 +210,14 @@
|
|||
</a>
|
||||
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
|
||||
</div>
|
||||
|
||||
<!-- Live deployment count. Hidden until the number arrives, so a self-hosted instance (where
|
||||
the endpoint does not exist) and a brand-new one (where the count is 0) show nothing at
|
||||
all rather than an empty frame or a zero. -->
|
||||
<p id="deployed-stat" hidden style="margin-top:28px;color:var(--muted);font-size:15px">
|
||||
<strong id="deployed-count" style="color:var(--text,inherit);font-variant-numeric:tabular-nums"></strong>
|
||||
screens deployed with ScreenTinker
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Intro video -->
|
||||
|
|
@ -630,6 +638,19 @@
|
|||
// replace it on the public marketing page with a hardcoded Contact Us
|
||||
// card. Other consumers of /api/subscription/plans (billing.js,
|
||||
// settings.js, admin.js) get the full list as before.
|
||||
/* Screens deployed. Only the deployment that collects install statistics answers this;
|
||||
everywhere else it 404s and the line stays hidden. Failures are silent by design —
|
||||
a marketing page must not show a broken stat, and there is nothing a visitor could
|
||||
do about it. */
|
||||
fetch('/api/public/stats')
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(s => {
|
||||
if (!s || !(s.screens > 0)) return;
|
||||
document.getElementById('deployed-count').textContent = s.screens.toLocaleString();
|
||||
document.getElementById('deployed-stat').hidden = false;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
|
||||
const grid = document.getElementById('pricingGrid');
|
||||
const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');
|
||||
|
|
|
|||
7
frontend/vendor/README.md
vendored
7
frontend/vendor/README.md
vendored
|
|
@ -4,9 +4,16 @@ Third-party libraries committed directly to the repo (not fetched from a CDN or
|
|||
from npm) so self-hosted / air-gapped instances work with no external dependency and no
|
||||
build step.
|
||||
|
||||
**Anything added here ships in the release tarball**, so it must carry its licence notice —
|
||||
a minified bundle usually has its headers stripped, which is exactly when the notice has to
|
||||
be kept as a separate file next to it. Record the licence below and add a `<name>.LICENSE`.
|
||||
|
||||
## redoc.standalone.js
|
||||
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
|
||||
- **Version:** 2.3.9
|
||||
- **Licence:** MIT — Copyright (c) 2015-present, Rebilly, Inc. Full text in
|
||||
[`redoc.LICENSE`](redoc.LICENSE). The bundle itself carries no header (stripped by the
|
||||
upstream minifier), which is why the notice is kept separately.
|
||||
- **Source:** https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js
|
||||
- **Why committed:** the API reference must render on offline instances — no CDN, no build step.
|
||||
- **Regenerate / update:**
|
||||
|
|
|
|||
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
Redoc — https://github.com/Redocly/redoc
|
||||
Version vendored here: 2.3.9 (see redoc.standalone.js)
|
||||
|
||||
The bundle in this directory is minified and its license headers were stripped upstream, so
|
||||
the notice is kept alongside it instead. MIT requires this notice to accompany the software
|
||||
wherever it is distributed, and redoc.standalone.js is included in the ScreenTinker release
|
||||
tarball.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present, Rebilly, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
112
scripts/android-license-check.js
Normal file
112
scripts/android-license-check.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Licence gate for the APK.
|
||||
*
|
||||
* node scripts/android-license-check.js [--sbom <path>]
|
||||
*
|
||||
* Resolves the real `releaseRuntimeClasspath` — every artifact that can end up inside the APK a
|
||||
* customer installs, transitive ones included — and checks each against android/licenses.json.
|
||||
*
|
||||
* Fails on an artifact nobody has recorded a licence for. That is the case worth catching:
|
||||
* org.json:json:20090211 reached customers because it arrived as a transitive dependency of
|
||||
* socket.io-client and nothing ever asked what licence it carried.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const ANDROID = path.join(ROOT, 'android');
|
||||
const POLICY = JSON.parse(fs.readFileSync(path.join(ANDROID, 'licenses.json'), 'utf8'));
|
||||
const SBOM_OUT = process.argv.includes('--sbom') ? process.argv[process.argv.indexOf('--sbom') + 1] : null;
|
||||
|
||||
function resolveClasspath() {
|
||||
const out = execFileSync('./gradlew', ['-q', 'app:dependencies', '--configuration', 'releaseRuntimeClasspath'],
|
||||
{ cwd: ANDROID, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' });
|
||||
const found = new Map();
|
||||
for (const raw of out.split('\n')) {
|
||||
// Gradle prints "group:name:requested -> resolved" when a version is upgraded; the resolved
|
||||
// one is what ships, so prefer the right-hand side.
|
||||
const m = raw.match(/([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([0-9][a-zA-Z0-9._-]*)(?:\s*->\s*([0-9][a-zA-Z0-9._-]*))?/);
|
||||
if (!m) continue;
|
||||
const [, group, name, requested, upgraded] = m;
|
||||
found.set(`${group}:${name}`, { group, name, version: upgraded || requested });
|
||||
}
|
||||
return [...found.values()].sort((a, b) => `${a.group}:${a.name}`.localeCompare(`${b.group}:${b.name}`));
|
||||
}
|
||||
|
||||
function licenceFor(a) {
|
||||
const coord = `${a.group}:${a.name}`;
|
||||
if (POLICY.denied[coord]) return { verdict: 'DENY', why: POLICY.denied[coord].why };
|
||||
if (POLICY.artifacts[coord]) return { verdict: 'ALLOW', ...POLICY.artifacts[coord] };
|
||||
// Longest matching group prefix wins, so a specific rule beats a broad one.
|
||||
const groups = Object.keys(POLICY.groups)
|
||||
.filter(g => a.group === g || a.group.startsWith(g + '.'))
|
||||
.sort((x, y) => y.length - x.length);
|
||||
if (groups.length) return { verdict: 'ALLOW', ...POLICY.groups[groups[0]] };
|
||||
return { verdict: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
const artifacts = resolveClasspath();
|
||||
if (!artifacts.length) {
|
||||
console.error('Resolved no artifacts — the gradle task did not run properly. Refusing to pass.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const results = artifacts.map(a => ({ ...a, ...licenceFor(a) }));
|
||||
const denied = results.filter(r => r.verdict === 'DENY');
|
||||
const unknown = results.filter(r => r.verdict === 'UNKNOWN');
|
||||
|
||||
for (const r of results.filter(r => r.verdict === 'ALLOW')) {
|
||||
for (const d of POLICY.denied_licenses) {
|
||||
if (new RegExp(d.match, 'i').test(r.license)) { denied.push({ ...r, why: `${r.license}: ${d.why}` }); }
|
||||
}
|
||||
}
|
||||
|
||||
const counts = results.reduce((m, r) => (m[r.license || '(unrecorded)'] = (m[r.license || '(unrecorded)'] || 0) + 1, m), {});
|
||||
console.log(`\nScope: ${results.length} artifacts on releaseRuntimeClasspath (everything that can enter the APK)\n`);
|
||||
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
|
||||
|
||||
if (SBOM_OUT) {
|
||||
const sbom = {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.5',
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: {
|
||||
type: 'application',
|
||||
name: 'screentinker-android-player',
|
||||
version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(),
|
||||
licenses: [{ license: { id: 'MIT' } }],
|
||||
},
|
||||
},
|
||||
components: results.map(r => ({
|
||||
type: 'library',
|
||||
name: `${r.group}:${r.name}`,
|
||||
version: r.version,
|
||||
purl: `pkg:maven/${r.group}/${r.name}@${r.version}`,
|
||||
licenses: r.license ? [{ license: { id: r.license } }] : [],
|
||||
})),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
|
||||
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
|
||||
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
if (denied.length) {
|
||||
failed = true;
|
||||
console.log('\nDENIED');
|
||||
denied.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}\n ${r.why}`));
|
||||
}
|
||||
if (unknown.length) {
|
||||
failed = true;
|
||||
console.log('\nUNRECORDED — a new dependency reached the APK with no licence on file.');
|
||||
console.log('Look it up, then add it to android/licenses.json with evidence, or exclude it.');
|
||||
unknown.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}`));
|
||||
}
|
||||
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: every artifact in the APK has a recorded, permitted licence.\n');
|
||||
process.exit(failed ? 1 : 0);
|
||||
184
scripts/license-check.js
Normal file
184
scripts/license-check.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Licence gate for the dependencies that actually SHIP.
|
||||
*
|
||||
* node scripts/license-check.js [--sbom <path>] [--include-dev]
|
||||
*
|
||||
* Run from a PRODUCTION install (`npm ci --omit=dev`). That is the whole point: a developer
|
||||
* checkout carries `sharp`, whose `@img/sharp-wasm32` declares LGPL-3.0-or-later. It is a test
|
||||
* fixture generator, it is devDependencies-only, and it never reaches a server — but a scanner
|
||||
* pointed at a dev tree reports LGPL and contradicts the answer we give customers. Auditing the
|
||||
* installed production tree is what makes the answer defensible.
|
||||
*
|
||||
* Exits non-zero on anything denied or unresolved, so CI fails before a licence can arrive
|
||||
* unnoticed through a transitive bump.
|
||||
*
|
||||
* No dependencies, deliberately — a gate that needs its own supply chain audited is worth less.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const INCLUDE_DEV = args.includes('--include-dev');
|
||||
const SBOM_OUT = args.includes('--sbom') ? args[args.indexOf('--sbom') + 1] : null;
|
||||
const SERVER_DIR = path.join(__dirname, '..', 'server');
|
||||
|
||||
/* ── policy ───────────────────────────────────────────────────────────────────
|
||||
* ALLOW: permissive, no distribution obligation beyond keeping the notice.
|
||||
* DENY: strong/network copyleft, plus licences we will not ship for other reasons.
|
||||
* Anything matching neither is REVIEW — it fails, and a human decides. Failing closed
|
||||
* matters more than being clever: the risk is a licence arriving that nobody looked at.
|
||||
*/
|
||||
const ALLOW = [
|
||||
/^MIT$/i, /^MIT-0$/i, /^ISC$/i, /^0BSD$/i, /^BSD-2-Clause$/i, /^BSD-3-Clause$/i,
|
||||
/^Apache-2\.0$/i, /^BlueOak-1\.0\.0$/i, /^Unlicense$/i, /^CC0-1\.0$/i, /^Python-2\.0$/i,
|
||||
/^WTFPL$/i, /^Zlib$/i, /^CC-BY-4\.0$/i,
|
||||
];
|
||||
|
||||
const DENY = [
|
||||
{ re: /\bAGPL/i, why: 'network copyleft — obligations trigger on serving, not distributing' },
|
||||
{ re: /\bGPL-[123]|\bGPLv[123]|(^|[^L])\bGPL\b/i, why: 'strong copyleft — links into a product we distribute commercially' },
|
||||
{ re: /\bSSPL/i, why: 'server-side public licence — not OSI-approved, service-scope obligations' },
|
||||
{ re: /\bCommons-Clause/i, why: 'commercial-use restriction' },
|
||||
{ re: /\bBUSL|Business Source/i, why: 'source-available, not open source' },
|
||||
{ re: /Good, not Evil|^JSON$/i, why: 'JSON Licence — field-of-use clause, Apache Category X, non-free per Debian/Fedora' },
|
||||
];
|
||||
|
||||
// Weak copyleft: file- or library-scoped, generally fine when merely linked, but never silently.
|
||||
const REVIEW = [/\bLGPL/i, /\bMPL/i, /\bEPL/i, /\bCDDL/i, /\bOSL/i, /\bEUPL/i, /\bCPL/i];
|
||||
|
||||
/*
|
||||
* Packages that ship a real licence FILE but declare no `license` field in package.json.
|
||||
* Each entry records what was read off disk, so this is a documented finding rather than a
|
||||
* blanket exemption. Re-verify if the version changes.
|
||||
*/
|
||||
const EXCEPTIONS = {
|
||||
'exif-parser': { license: 'MIT', evidence: 'LICENSE.md — "The MIT License"' },
|
||||
'thirty-two': { license: 'MIT', evidence: 'LICENSE.txt — MIT, Copyright (c) 2011 Chris Umbel' },
|
||||
'screentinker': { license: 'MIT', evidence: 'repository root LICENSE' },
|
||||
};
|
||||
|
||||
function classify(id) {
|
||||
if (!id) return { verdict: 'UNKNOWN' };
|
||||
for (const d of DENY) if (d.re.test(id)) return { verdict: 'DENY', why: d.why };
|
||||
// A GPL-with-exception (Classpath, linking) is not the thing we are guarding against.
|
||||
if (/WITH .*exception/i.test(id)) return { verdict: 'REVIEW', why: 'copyleft with a linking exception' };
|
||||
for (const r of REVIEW) if (r.test(id)) return { verdict: 'REVIEW', why: 'weak copyleft' };
|
||||
// Composite expressions: every term must be allowed.
|
||||
const terms = id.split(/\s+(?:OR|AND)\s+|[()]/).map(s => s.trim()).filter(Boolean);
|
||||
if (terms.length && terms.every(t => ALLOW.some(a => a.test(t)))) return { verdict: 'ALLOW' };
|
||||
return { verdict: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
function readLicense(dir) {
|
||||
let pkg;
|
||||
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
|
||||
let lic = pkg.license;
|
||||
if (lic && typeof lic === 'object') lic = lic.type;
|
||||
if (!lic && Array.isArray(pkg.licenses)) lic = pkg.licenses.map(l => l.type || l).join(' OR ');
|
||||
return { name: pkg.name, version: pkg.version, license: lic || null };
|
||||
}
|
||||
|
||||
/*
|
||||
* `npm ls` exits non-zero for any tree problem — an extraneous package, a peer-dep complaint —
|
||||
* while still printing the full listing. Treating that as fatal would turn a routine tree quirk
|
||||
* into an unexplained CI failure, and worse, a licence check that never actually ran. Read the
|
||||
* output either way; a genuinely empty result is the only thing worth aborting on.
|
||||
*/
|
||||
function listInstalled() {
|
||||
const argv = ['ls', ...(INCLUDE_DEV ? [] : ['--omit=dev']), '--all', '--parseable'];
|
||||
const opts = { cwd: SERVER_DIR, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' };
|
||||
try {
|
||||
return execFileSync('npm', argv, opts);
|
||||
} catch (e) {
|
||||
if (e.stdout && e.stdout.trim()) return e.stdout;
|
||||
console.error('npm ls produced no output:\n' + (e.stderr || e.message));
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = listInstalled().split('\n').filter(Boolean);
|
||||
|
||||
const pkgs = [];
|
||||
const seen = new Set();
|
||||
for (const d of dirs) {
|
||||
const info = readLicense(d);
|
||||
if (!info || !info.name) continue;
|
||||
const key = `${info.name}@${info.version}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
let license = info.license;
|
||||
let note = null;
|
||||
if (!license && EXCEPTIONS[info.name]) {
|
||||
license = EXCEPTIONS[info.name].license;
|
||||
note = `no license field; ${EXCEPTIONS[info.name].evidence}`;
|
||||
}
|
||||
pkgs.push({ ...info, license, note, ...classify(license) });
|
||||
}
|
||||
|
||||
const denied = pkgs.filter(p => p.verdict === 'DENY');
|
||||
const review = pkgs.filter(p => p.verdict === 'REVIEW');
|
||||
const unknown = pkgs.filter(p => p.verdict === 'UNKNOWN');
|
||||
|
||||
const counts = pkgs.reduce((m, p) => (m[p.license || '(none)'] = (m[p.license || '(none)'] || 0) + 1, m), {});
|
||||
console.log(`\nScope: ${pkgs.length} packages (${INCLUDE_DEV ? 'INCLUDING dev' : 'production only, --omit=dev'})\n`);
|
||||
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
|
||||
|
||||
if (SBOM_OUT) {
|
||||
// CycloneDX 1.5, hand-built. A standard format customers and underwriters recognise, without
|
||||
// taking a dependency on a generator to produce it.
|
||||
const sbom = {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.5',
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: {
|
||||
type: 'application',
|
||||
name: 'screentinker',
|
||||
version: fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(),
|
||||
licenses: [{ license: { id: 'MIT' } }],
|
||||
},
|
||||
properties: [{ name: 'screentinker:scope', value: INCLUDE_DEV ? 'all' : 'production' }],
|
||||
},
|
||||
components: pkgs
|
||||
.filter(p => p.name !== 'screentinker')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(p => ({
|
||||
type: 'library',
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
purl: `pkg:npm/${p.name.replace('@', '%40')}@${p.version}`,
|
||||
licenses: p.license ? [{ license: /[()]| OR | AND /.test(p.license) ? { name: p.license } : { id: p.license } }] : [],
|
||||
})),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
|
||||
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
|
||||
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
if (denied.length) {
|
||||
failed = true;
|
||||
console.log('\nDENIED');
|
||||
denied.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license}\n ${p.why}`));
|
||||
}
|
||||
if (unknown.length) {
|
||||
failed = true;
|
||||
console.log('\nUNRESOLVED — no recognised licence. Read the package, then add it to EXCEPTIONS');
|
||||
console.log('with the evidence, or remove the dependency.');
|
||||
unknown.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license || '(no license field)'}`));
|
||||
}
|
||||
if (review.length) {
|
||||
// Not fatal, but never silent — weak copyleft is a judgement call, and the judgement should be
|
||||
// made by a person who knows it is being made.
|
||||
console.log('\nREVIEW (not failing)');
|
||||
review.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license} ${p.why}`));
|
||||
}
|
||||
|
||||
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: no denied or unresolved licences.\n');
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
@ -641,6 +641,19 @@ const migrations = [
|
|||
// it can do nothing and must be respected.
|
||||
'ALTER TABLE devices ADD COLUMN capabilities TEXT',
|
||||
|
||||
// Opt-in install statistics, COLLECTOR side only — inert unless TELEMETRY_COLLECTOR=1, which
|
||||
// is the hosted deployment. Keyed by instance_id and upserted rather than appended, so it is a
|
||||
// table of current state ("this install last reported N screens") rather than an event log that
|
||||
// grows without bound on a box nobody prunes. Answering "how many screens are deployed" needs
|
||||
// the latest row per install, never the history.
|
||||
`CREATE TABLE IF NOT EXISTS telemetry_reports (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT,
|
||||
screen_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
)`,
|
||||
|
||||
];
|
||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||
// error means the column is already present (expected on a migrated DB) - benign.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const { preCmp } = require('./version-precedence');
|
||||
|
||||
/*
|
||||
* Should a BrightSign player replace its own host package (autorun.zip)?
|
||||
*
|
||||
|
|
@ -54,7 +56,9 @@ function compareVersions(a, b) {
|
|||
if (A.pre === B.pre) return 0;
|
||||
if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1
|
||||
if (B.pre === null) return -1;
|
||||
return A.pre < B.pre ? -1 : 1; // rc1 < rc2, lexicographic is right for our naming
|
||||
// Natural compare, NOT lexicographic: rc10 must outrank rc9. This file carried the same
|
||||
// "lexicographic is right for our naming" assumption that broke the Android OTA path.
|
||||
return preCmp(A.pre, B.pre);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ const { rollingCounter, bump, read } = require('./rolling-counter');
|
|||
const rateBackoffCtr = rollingCounter();
|
||||
|
||||
// --- minimal semver-ish parse/compare (no dependency) ---
|
||||
const { preCmp } = require('./version-precedence');
|
||||
|
||||
function parseVer(v) {
|
||||
if (typeof v !== 'string') return null;
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
||||
|
|
@ -54,8 +56,10 @@ function cmpParsed(a, b) {
|
|||
if (a.pre === b.pre) return 0;
|
||||
if (a.pre === null) return 1; // release outranks a prerelease of the same core
|
||||
if (b.pre === null) return -1;
|
||||
// lexical prerelease compare — fine for beta1..beta9 (cores decide everything else).
|
||||
return a.pre < b.pre ? -1 : (a.pre > b.pre ? 1 : 0);
|
||||
// Natural prerelease compare: digit runs numerically, so alpha8 < alpha9 < alpha10 < alpha11.
|
||||
// A plain lexical compare (what this used to do) put every build from alpha10 onward BELOW
|
||||
// alpha8, so the check answered client-newer and the fleet could not be moved forward at all.
|
||||
return preCmp(a.pre, b.pre);
|
||||
}
|
||||
function cmp(a, b) { const pa = parseVer(a), pb = parseVer(b); return (!pa || !pb) ? null : cmpParsed(pa, pb); }
|
||||
|
||||
|
|
|
|||
|
|
@ -312,6 +312,9 @@ const COMMAND_CAPABILITY = {
|
|||
launch: 'system.restart_player',
|
||||
refresh: 'system.restart_player',
|
||||
update: 'system.self_update',
|
||||
// Clearing the staged-APK cache is part of the same self-update surface: a player that can
|
||||
// update itself is a player that can hold a bad download and needs a way to drop it.
|
||||
clear_update_cache: 'system.self_update',
|
||||
|
||||
// display
|
||||
screen_on: 'display.power',
|
||||
|
|
|
|||
200
server/lib/telemetry.js
Normal file
200
server/lib/telemetry.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Opt-in install statistics.
|
||||
*
|
||||
* WHY THIS EXISTS: there is no way to answer "how many screens run ScreenTinker?" — the product is
|
||||
* self-hostable by design, so most installs are invisible to us on purpose. This asks, once, and
|
||||
* only reports if the operator says yes.
|
||||
*
|
||||
* WHAT IS SENT — the whole payload, three fields:
|
||||
*
|
||||
* { instance_id, version, screen_count }
|
||||
*
|
||||
* and nothing else. No hostnames, no addresses, no organization or user names, no device names,
|
||||
* no content or filenames, no user counts. The list is short on purpose: every field added costs
|
||||
* participation, and participation is the only thing that makes the resulting number worth
|
||||
* quoting. Anyone can verify it — the payload is built in `payload()` below, in one place, and
|
||||
* `getLastReport()` shows an operator the exact bytes last sent.
|
||||
*
|
||||
* `instance_id` is a random UUID generated on first use and kept in app_settings. It carries no
|
||||
* information about the install; its only job is to let two reports from the same server be
|
||||
* recognised as the same server, so a count is a count rather than a sum of duplicates. That does
|
||||
* make a report PSEUDONYMOUS rather than anonymous, and the wording shown to operators says so.
|
||||
*
|
||||
* ⚠️ Restoring a backup or cloning a VM carries the id with it, so two installs report as one.
|
||||
* Deliberate: under-counting is the honest failure here, and the alternative (re-identifying on
|
||||
* some hardware signal) means collecting exactly the kind of thing this file promises not to.
|
||||
*
|
||||
* ⚠️ Opt-in populations are self-selected, so the total is a FLOOR — "at least N screens" — never
|
||||
* a basis for extrapolating a fleet size.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const appSettings = require('./app-settings');
|
||||
|
||||
const KEY_ID = 'telemetry_instance_id';
|
||||
const KEY_ENABLED = 'telemetry_enabled'; // unset = never asked
|
||||
const KEY_LAST = 'telemetry_last_report'; // last SUCCESSFUL send
|
||||
const KEY_LAST_ERROR = 'telemetry_last_error';// last FAILED attempt — see getLastError
|
||||
|
||||
/*
|
||||
* Where reports go. TWO independent destinations, deliberately:
|
||||
*
|
||||
* SCREENTINKER_ENDPOINT hard-wired, and reached only when the operator has switched sharing on.
|
||||
* Not overridable — an "override" that silently redirected the shared
|
||||
* report would make the opt-in mean something different from what it says.
|
||||
*
|
||||
* TELEMETRY_EXTRA_ENDPOINT an operator's OWN collector, for their own fleet numbers. Additional,
|
||||
* never a replacement, and named so it cannot be mistaken for one. It is
|
||||
* sent independently of the sharing toggle: it is their server posting to
|
||||
* their host, so our opt-in has no business gating it. An operator who
|
||||
* wants internal statistics and nothing leaving for us sets this and
|
||||
* leaves sharing off — that combination is supported on purpose.
|
||||
*/
|
||||
const SCREENTINKER_ENDPOINT = 'https://stats.screentinker.com/api/telemetry/report';
|
||||
const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily; this is a count, not a metric
|
||||
const FIRST_REPORT_DELAY_MS = 5 * 60 * 1000; // let boot settle before any outbound call
|
||||
|
||||
let timer = null;
|
||||
|
||||
/* The instance's own id, minted on first read. Stable for the life of the install. */
|
||||
function instanceId() {
|
||||
let id = appSettings.get(KEY_ID, null);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
appSettings.set(KEY_ID, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/*
|
||||
* 'unasked' | 'on' | 'off'. The distinction matters: 'unasked' is what the prompt keys on, and a
|
||||
* declined install must be remembered as 'off' rather than falling back to 'unasked' and being
|
||||
* asked again on every update — re-prompting is how telemetry gets patched out by annoyed admins.
|
||||
*/
|
||||
function state() {
|
||||
const v = appSettings.get(KEY_ENABLED, undefined);
|
||||
if (v === undefined) return 'unasked';
|
||||
return (v === 'true' || v === '1') ? 'on' : 'off';
|
||||
}
|
||||
|
||||
function setEnabled(enabled) {
|
||||
appSettings.setBool(KEY_ENABLED, !!enabled);
|
||||
return state();
|
||||
}
|
||||
|
||||
/* Every field that leaves this install, built in one place so it can be audited at a glance. */
|
||||
function payload(db) {
|
||||
return {
|
||||
instance_id: instanceId(),
|
||||
version: require('../version'),
|
||||
screen_count: countScreens(db),
|
||||
};
|
||||
}
|
||||
|
||||
// Devices that have actually been paired — a provisioning row nobody ever connected is not a
|
||||
// screen, and counting it would overstate exactly the number this exists to state honestly.
|
||||
function countScreens(db) {
|
||||
try {
|
||||
return db.prepare('SELECT COUNT(*) AS c FROM devices WHERE device_token IS NOT NULL').get().c;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The address an operator may need to allowlist for the shared report. Hard-wired. */
|
||||
function endpoint() { return SCREENTINKER_ENDPOINT; }
|
||||
|
||||
/* The operator's own collector, if they configured one. Null when they have not. */
|
||||
function extraEndpoint() { return process.env.TELEMETRY_EXTRA_ENDPOINT || null; }
|
||||
|
||||
/*
|
||||
* Everywhere this report is going, right now, and why — so the UI can list every destination
|
||||
* rather than implying there is only one. Sharing gates OUR endpoint alone.
|
||||
*/
|
||||
function destinations() {
|
||||
const out = [];
|
||||
if (state() === 'on') out.push({ url: endpoint(), kind: 'screentinker' });
|
||||
const extra = extraEndpoint();
|
||||
if (extra) out.push({ url: extra, kind: 'extra' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* What was last sent, and when. Surfaced in Settings so an operator can check rather than trust. */
|
||||
function getLastReport() {
|
||||
const raw = appSettings.get(KEY_LAST, null);
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/*
|
||||
* The last FAILED attempt, kept separately from the last success.
|
||||
*
|
||||
* A self-hosted server frequently sits behind egress filtering, so "enabled but nothing arrives"
|
||||
* is the normal failure and it is otherwise completely silent — the operator sees "nothing has
|
||||
* been sent" and has no way to tell a blocked firewall from a broken feature. Recording the
|
||||
* failure lets the UI name the host that needs unblocking instead.
|
||||
*/
|
||||
function getLastError() {
|
||||
const raw = appSettings.get(KEY_LAST_ERROR, null);
|
||||
if (!raw) return null; // '' is how a success clears it
|
||||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/*
|
||||
* Send one report. Returns {sent:false, reason} rather than throwing — a stats call must never be
|
||||
* able to affect the running server, so every failure path here is quiet and local.
|
||||
*/
|
||||
async function postTo(url, body) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return res.ok ? { sent: true } : { sent: false, reason: `http_${res.status}` };
|
||||
} catch (err) {
|
||||
// Offline, DNS failure, blocked egress — all normal for a self-hosted box, none of them news
|
||||
// in the log, but all worth surfacing in the UI so the operator can act on it.
|
||||
return { sent: false, reason: err && err.name === 'TimeoutError' ? 'timeout' : 'network' };
|
||||
}
|
||||
}
|
||||
|
||||
async function report(db, { urls = null } = {}) {
|
||||
// `urls` is a test seam. Normal callers get destinations() — sharing gates ours, an operator's
|
||||
// own collector is independent of it.
|
||||
const targets = urls || destinations();
|
||||
if (!targets.length) return { sent: false, reason: 'not_enabled', results: [] };
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const body = payload(db);
|
||||
|
||||
// Every destination is attempted, independently. One unreachable collector must not stop the
|
||||
// other from receiving — a blocked corporate firewall on their host should not cost us the
|
||||
// shared count, and our endpoint being down should not cost them their own fleet numbers.
|
||||
const results = [];
|
||||
for (const t of targets) results.push({ ...t, ...(await postTo(t.url, body)) });
|
||||
|
||||
const failed = results.filter(r => !r.sent);
|
||||
if (results.some(r => r.sent)) appSettings.set(KEY_LAST, JSON.stringify({ at: now(), body, results }));
|
||||
// Keep only a LIVE complaint: record what is still failing, and clear it once nothing is.
|
||||
appSettings.set(KEY_LAST_ERROR, failed.length
|
||||
? JSON.stringify({ at: now(), reason: failed[0].reason, url: failed[0].url, failed })
|
||||
: '');
|
||||
|
||||
return { sent: failed.length === 0, results, body, reason: failed[0]?.reason };
|
||||
}
|
||||
|
||||
function start(db) {
|
||||
if (timer) return;
|
||||
const tick = () => { report(db).catch(() => {}); };
|
||||
setTimeout(tick, FIRST_REPORT_DELAY_MS).unref?.();
|
||||
timer = setInterval(tick, REPORT_INTERVAL_MS);
|
||||
timer.unref?.(); // never hold the process open for a stats timer
|
||||
}
|
||||
|
||||
function stop() { if (timer) { clearInterval(timer); timer = null; } }
|
||||
|
||||
module.exports = { instanceId, state, setEnabled, payload, report, endpoint, extraEndpoint, destinations, getLastReport, getLastError, start, stop };
|
||||
62
server/lib/version-precedence.js
Normal file
62
server/lib/version-precedence.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Precedence for PRERELEASE identifiers — the `-alpha11` half of `1.9.34-alpha11`.
|
||||
*
|
||||
* ⚠️ WHY THIS EXISTS: a plain string compare is what semver specifies for a single alphanumeric
|
||||
* identifier, and it is wrong for how this project actually names builds. `"alpha11" < "alpha8"`
|
||||
* because `'1' < '8'`, so EVERY build from alpha10 onward sorted below alpha8 and alpha9. The OTA
|
||||
* check then answered `client-newer` and refused to offer the update at all — a fleet on alpha8
|
||||
* could not be moved forward, silently, with the server reporting the newer build as `latest` in
|
||||
* the same breath. Two comparators carried the same assumption, both with a comment saying lexical
|
||||
* was "fine for our naming"; it was fine only while the counter stayed below 10.
|
||||
*
|
||||
* The rule here is natural ordering: split each identifier into digit and non-digit runs and
|
||||
* compare digit runs NUMERICALLY. That gives what a human means by the name — alpha8 < alpha9 <
|
||||
* alpha10 < alpha11 — while leaving everything else alphabetical, so beta still outranks alpha and
|
||||
* rc still outranks beta.
|
||||
*
|
||||
* Dot-separated identifiers are compared one at a time per semver, and a shorter run of identifiers
|
||||
* loses when all preceding ones are equal (`alpha` < `alpha.1`), so a future move to the semver-
|
||||
* correct `-alpha.11` form keeps working without another change here.
|
||||
*
|
||||
* Deliberately NOT handled: whether a prerelease outranks a release. That is the caller's rule —
|
||||
* both callers already implement it, and each has its own exceptions (ota-breaker treats the legacy
|
||||
* `-patchN` scheme as released).
|
||||
*/
|
||||
|
||||
// Compare one identifier, digit runs numerically. "alpha10" -> ["alpha", "10"].
|
||||
function naturalCmp(x, y) {
|
||||
const rx = String(x).match(/\d+|\D+/g) || [];
|
||||
const ry = String(y).match(/\d+|\D+/g) || [];
|
||||
for (let i = 0; i < Math.max(rx.length, ry.length); i++) {
|
||||
const a = rx[i], b = ry[i];
|
||||
if (a === undefined) return -1; // "alpha" < "alpha1"
|
||||
if (b === undefined) return 1;
|
||||
const aNum = /^\d+$/.test(a), bNum = /^\d+$/.test(b);
|
||||
if (aNum && bNum) {
|
||||
// Numeric, so 10 beats 8 — the whole point of this file.
|
||||
if (Number(a) !== Number(b)) return Number(a) < Number(b) ? -1 : 1;
|
||||
} else if (a !== b) {
|
||||
// A digit run sorts below a word run, matching semver's numeric-identifiers-first rule.
|
||||
if (aNum !== bNum) return aNum ? -1 : 1;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Full prerelease precedence: dot-separated identifiers, each compared naturally. */
|
||||
function preCmp(a, b) {
|
||||
if (a === b) return 0;
|
||||
const as = String(a).split('.'), bs = String(b).split('.');
|
||||
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
||||
if (as[i] === undefined) return -1; // "alpha" < "alpha.1"
|
||||
if (bs[i] === undefined) return 1;
|
||||
const c = naturalCmp(as[i], bs[i]);
|
||||
if (c !== 0) return c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = { preCmp, naturalCmp };
|
||||
37
server/package-lock.json
generated
37
server/package-lock.json
generated
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.34-alpha7",
|
||||
"version": "1.9.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "screentinker",
|
||||
"version": "1.9.34-alpha7",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.2.1",
|
||||
"@jsquash/avif": "^1.3.0",
|
||||
|
|
@ -21,7 +22,7 @@
|
|||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
|
|
@ -1941,9 +1942,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
|
|
@ -3434,9 +3435,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
|
|
@ -3544,9 +3545,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3913,9 +3914,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -4931,9 +4932,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.34-alpha7",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"description": "ScreenTinker - Digital Signage Management Server",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -23,7 +24,7 @@
|
|||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
|
|
|
|||
|
|
@ -426,6 +426,50 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
|
|||
res.json({ enabled });
|
||||
});
|
||||
|
||||
// ===================== Opt-in install statistics =====================
|
||||
// Returns the decision state, the EXACT payload that would be sent, and what was last actually
|
||||
// sent. Handing over the real payload rather than a description is the point: an operator can
|
||||
// check instead of trusting a sentence, and the code is public so a mismatch would be visible.
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
router.get('/telemetry', requirePlatformAdmin, (req, res) => {
|
||||
res.json({
|
||||
state: telemetry.state(), // 'unasked' | 'on' | 'off'
|
||||
payload: telemetry.payload(db), // what WOULD be sent, right now
|
||||
endpoint: telemetry.endpoint(), // ours — the host an operator may need to allowlist
|
||||
extra_endpoint: telemetry.extraEndpoint(),// their own collector, if configured
|
||||
destinations: telemetry.destinations(), // everywhere it actually goes, right now
|
||||
last_report: telemetry.getLastReport(), // what was actually sent, and when
|
||||
last_error: telemetry.getLastError(), // why the last attempt failed, if it did
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/telemetry', requirePlatformAdmin, async (req, res) => {
|
||||
// Both answers are recorded. Declining must persist as 'off' rather than staying 'unasked',
|
||||
// or the prompt returns after every update — which is how telemetry earns its bad name.
|
||||
const enabled = !!req.body.enabled;
|
||||
const state = telemetry.setEnabled(enabled);
|
||||
logActivity(req.user.id, 'admin_set_telemetry', `enabled: ${enabled}`, null, getClientIp(req), null);
|
||||
|
||||
// Send once, now, rather than waiting for the next daily tick. Two reasons: the operator is
|
||||
// standing right here and "nothing has been sent" for the next 24h reads as broken, and an
|
||||
// egress-filtered network fails HERE where we can name the host to unblock — instead of
|
||||
// failing silently tonight where nobody is watching.
|
||||
let first = null;
|
||||
if (enabled) first = await telemetry.report(db);
|
||||
|
||||
res.json({
|
||||
state,
|
||||
payload: telemetry.payload(db),
|
||||
endpoint: telemetry.endpoint(),
|
||||
extra_endpoint: telemetry.extraEndpoint(),
|
||||
destinations: telemetry.destinations(),
|
||||
first_report: first && { sent: first.sent, reason: first.reason || null },
|
||||
last_report: telemetry.getLastReport(),
|
||||
last_error: telemetry.getLastError(),
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== Version update indicator =====================
|
||||
// check-update = requireAdmin — a read-only GHCR poll, operational.
|
||||
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the
|
||||
|
|
|
|||
68
server/routes/telemetry-collector.js
Normal file
68
server/routes/telemetry-collector.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Opt-in install statistics — the COLLECTOR side, plus the public aggregate the marketing
|
||||
* page reads.
|
||||
*
|
||||
* Mounted only when TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither
|
||||
* route. That gate is doing real work on both: the report endpoint is unauthenticated, and
|
||||
* the aggregate would otherwise let any anonymous visitor read a private instance's screen
|
||||
* count off its own landing page.
|
||||
*
|
||||
* A factory rather than a bare router so the database can be injected — which is what lets
|
||||
* this be tested at all. It was previously inline in server.js and had no tests.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
|
||||
module.exports = function createTelemetryCollectorRouter(db) {
|
||||
const router = express.Router();
|
||||
|
||||
/*
|
||||
* Deliberately unauthenticated: a self-hosted instance has no credential with us, and
|
||||
* issuing one would mean an enrolment handshake for what is a three-integer postcard.
|
||||
*
|
||||
* Upsert keyed on instance_id, so an install that reports daily occupies one row forever
|
||||
* rather than 365 a year. Nothing here reads or stores the request IP — receiving one is
|
||||
* unavoidable, logging it would quietly make a pseudonymous report an identifiable one.
|
||||
*/
|
||||
router.post('/telemetry/report', express.json({ limit: '2kb' }), (req, res) => {
|
||||
const { instance_id: id, version, screen_count: screens } = req.body || {};
|
||||
// Validate rather than trust: this endpoint is open, so a malformed or hostile body must
|
||||
// land as a 400, never as a row that poisons the count it exists to produce.
|
||||
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) return res.status(400).json({ error: 'bad instance_id' });
|
||||
if (version != null && (typeof version !== 'string' || version.length > 40)) return res.status(400).json({ error: 'bad version' });
|
||||
if (!Number.isInteger(screens) || screens < 0 || screens > 100000) return res.status(400).json({ error: 'bad screen_count' });
|
||||
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, strftime('%s','now'), strftime('%s','now'))
|
||||
ON CONFLICT(instance_id) DO UPDATE SET
|
||||
version = excluded.version, screen_count = excluded.screen_count, last_seen = excluded.last_seen`)
|
||||
.run(id, version || null, screens);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/*
|
||||
* The aggregate, for the marketing page. An aggregate across every install that reports,
|
||||
* so it discloses nothing about any one of them.
|
||||
*
|
||||
* Cached, because this sits on a public landing page and the number moves in hours, not
|
||||
* milliseconds. A scraper hitting it in a loop costs one query per interval, not per request.
|
||||
*/
|
||||
const STATS_TTL_MS = 5 * 60 * 1000;
|
||||
let statsCache = { at: 0, body: null };
|
||||
|
||||
router.get('/public/stats', (req, res) => {
|
||||
const now = Date.now();
|
||||
if (!statsCache.body || now - statsCache.at > STATS_TTL_MS) {
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) AS installs, COALESCE(SUM(screen_count), 0) AS screens FROM telemetry_reports'
|
||||
).get();
|
||||
statsCache = { at: now, body: { screens: row.screens, installs: row.installs } };
|
||||
}
|
||||
// Public and cacheable, but never for long by a shared cache: the number is meant to climb.
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.json(statsCache.body);
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
@ -1206,6 +1206,14 @@ function renderDirectorySearch(c) {
|
|||
|
||||
// ----- on-screen keyboard (drives the same filter path as typing) -----
|
||||
if (cfg.show_onscreen_keyboard) {
|
||||
/* Tell the platform not to raise ITS keyboard for this field. We autofocus a real
|
||||
<input>, which on Android is the signal to throw the system IME over the bottom of
|
||||
the screen - directly on top of the keyboard we draw below, so a directory panel
|
||||
showed Google's keyboard (mic, GIF and emoji keys included) and never showed its
|
||||
own. The buttons write input.value directly, so suppressing the platform keyboard
|
||||
costs nothing here. Ignored by browsers that don't know inputmode, which is the
|
||||
right fallback: a desktop preview keeps behaving exactly as before. */
|
||||
input.setAttribute('inputmode', 'none');
|
||||
var kb = document.getElementById('keyboard');
|
||||
function press(ch) { input.value += ch; try { input.focus(); } catch(e){} onInput(); }
|
||||
['1234567890','qwertyuiop','asdfghjkl','zxcvbnm'].forEach(function(r){
|
||||
|
|
|
|||
|
|
@ -977,6 +977,22 @@ app.get('/api/version', (req, res) => {
|
|||
// Public status page
|
||||
app.use('/api/status', require('./routes/status'));
|
||||
|
||||
/*
|
||||
* Opt-in install statistics — the COLLECTOR side, plus the public aggregate the marketing
|
||||
* page reads. Both live in routes/telemetry-collector.js; both are mounted only when
|
||||
* TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither.
|
||||
*/
|
||||
if (process.env.TELEMETRY_COLLECTOR === '1') {
|
||||
/* `require('./db/database').db`, not the module-scope `db` — that binding is declared far
|
||||
below this line, so naming it here throws "Cannot access 'db' before initialization" at
|
||||
load and the process never starts. The inline handler this replaced only touched `db`
|
||||
inside a request callback, which runs long after the binding exists; passing it to a
|
||||
factory made the reference eager. Every neighbouring call site in this region resolves
|
||||
the same lazy way. */
|
||||
app.use('/api', require('./routes/telemetry-collector')(require('./db/database').db));
|
||||
console.log('[telemetry] collector enabled at POST /api/telemetry/report (+ GET /api/public/stats)');
|
||||
}
|
||||
|
||||
// #146 BILLING: Usage Report on its OWN route (NOT part of /api/status — billing is revenue
|
||||
// data and a heavier aggregate than the hot status path). bearerAuth is the dual front door:
|
||||
// a 'billing:read' API token (Bearer st_...) OR a JWT session both reach it; the route's
|
||||
|
|
@ -1214,6 +1230,7 @@ startContentExpiry(io);
|
|||
const { startAlertService } = require('./services/alerts');
|
||||
startAlertService(io);
|
||||
|
||||
|
||||
// Start activation-nudge sweep (T+3 onboarding nudge; gated on HOSTED_INSTANCE)
|
||||
const { startActivationNudge } = require('./services/activationNudge');
|
||||
startActivationNudge();
|
||||
|
|
@ -1248,6 +1265,13 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|||
|
||||
// Handle provisioning via WebSocket notification
|
||||
const { db } = require('./db/database');
|
||||
|
||||
// Opt-in install statistics — REPORTER side. Sends nothing until an operator says yes; the timer
|
||||
// is unref'd so it can never hold the process open, and every failure path is silent and local.
|
||||
// Must sit AFTER the `db` binding above: `const` is hoisted but uninitialised, so calling this
|
||||
// earlier in the file throws "Cannot access 'db' before initialization" at load.
|
||||
require('./lib/telemetry').start(db);
|
||||
|
||||
const originalProvisionRoute = require('./routes/provisioning');
|
||||
|
||||
// #161: device-owner QR provisioning. Returns the AOSP provisioning payload (DPC component + APK
|
||||
|
|
|
|||
|
|
@ -59,6 +59,24 @@ test('show_onscreen_keyboard flag is carried into the page config', async () =>
|
|||
assert.ok(html.includes('"show_onscreen_keyboard":false'), 'keyboard flag inlined (page hides the keyboard when false)');
|
||||
});
|
||||
|
||||
test('the built-in keyboard suppresses the platform one', async () => {
|
||||
// The page autofocuses a real <input>, which on Android raises the system IME over the
|
||||
// bottom of the screen - covering the keyboard this widget draws itself. A panel showed
|
||||
// Gboard, complete with a mic key, and never showed its own keyboard.
|
||||
seed('search_kb_on', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: true });
|
||||
const { html } = await fetchRender('search_kb_on');
|
||||
assert.ok(/setAttribute\(\s*'inputmode'\s*,\s*'none'\s*\)/.test(html),
|
||||
'page tells the platform not to raise its own keyboard');
|
||||
|
||||
// Only when we are drawing one. With the built-in keyboard off there is nothing to cover,
|
||||
// and the platform keyboard is the only way left to type.
|
||||
seed('search_kb_off2', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
|
||||
const off = await fetchRender('search_kb_off2');
|
||||
assert.ok(off.html.includes('"show_onscreen_keyboard":false'), 'flag inlined as false');
|
||||
assert.ok(!/inputmode="none"/.test(off.html),
|
||||
'the input is not statically marked inputmode=none - suppression is gated on the flag at runtime');
|
||||
});
|
||||
|
||||
test('missing source -> friendly fallback page, not a 500', async () => {
|
||||
seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
|
||||
const { status, html } = await fetchRender('search_missing');
|
||||
|
|
|
|||
94
server/test/email-nodemailer-live.test.js
Normal file
94
server/test/email-nodemailer-live.test.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// The rest of the email tests mock nodemailer through require.cache, which proves our code
|
||||
// calls sendMail correctly but says nothing about whether nodemailer still ACCEPTS what we
|
||||
// hand it. That gap is exactly where a dependency bump breaks sending: the suite stays green
|
||||
// while mail stops leaving the building.
|
||||
//
|
||||
// This drives the real library against a throwaway SMTP server on a loopback port, using the
|
||||
// option and message shapes services/email.js actually builds. No network, no credentials.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const net = require('node:net');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { buildSmtpMessage } = require('../services/email');
|
||||
|
||||
// A minimal SMTP server that answers enough of the protocol to accept one message and
|
||||
// records the conversation, so assertions can be made about what went over the wire.
|
||||
function startFakeSmtp() {
|
||||
const seen = { commands: [], data: '' };
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer((sock) => {
|
||||
let inData = false;
|
||||
sock.write('220 fake.local ESMTP\r\n');
|
||||
sock.on('data', (buf) => {
|
||||
const chunk = buf.toString();
|
||||
if (inData) {
|
||||
seen.data += chunk;
|
||||
if (/\r\n\.\r\n/.test(seen.data)) { inData = false; sock.write('250 OK queued\r\n'); }
|
||||
return;
|
||||
}
|
||||
for (const line of chunk.split('\r\n').filter(Boolean)) {
|
||||
seen.commands.push(line);
|
||||
const cmd = line.split(' ')[0].toUpperCase();
|
||||
if (cmd === 'EHLO' || cmd === 'HELO') sock.write('250-fake.local\r\n250 SIZE 10485760\r\n');
|
||||
else if (cmd === 'MAIL' || cmd === 'RCPT') sock.write('250 OK\r\n');
|
||||
else if (cmd === 'DATA') { inData = true; sock.write('354 send it\r\n'); }
|
||||
else if (cmd === 'QUIT') { sock.write('221 bye\r\n'); sock.end(); }
|
||||
else sock.write('250 OK\r\n');
|
||||
}
|
||||
});
|
||||
sock.on('error', () => {}); // a client hanging up mid-conversation is not a test failure
|
||||
});
|
||||
srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen }));
|
||||
});
|
||||
}
|
||||
|
||||
test('the installed nodemailer accepts the options and messages we build', async () => {
|
||||
const { srv, port, seen } = await startFakeSmtp();
|
||||
try {
|
||||
// The shape getSmtpTransporter() constructs.
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
// The object `from` form, used when a display name overrides the configured one.
|
||||
await transporter.sendMail({
|
||||
from: { name: 'ScreenTinker', address: 'noreply@example.com' },
|
||||
to: 'user@x.com',
|
||||
subject: '[ScreenTinker] Hello',
|
||||
html: '<p>hi there</p>',
|
||||
text: 'hi there',
|
||||
});
|
||||
|
||||
assert.ok(seen.commands.some(c => /^EHLO/i.test(c)), 'greets the server');
|
||||
assert.ok(seen.commands.some(c => /^MAIL FROM:<noreply@example\.com>/i.test(c)),
|
||||
'envelope sender is the configured address, not the display name');
|
||||
assert.ok(seen.commands.some(c => /^RCPT TO:<user@x\.com>/i.test(c)), 'envelope recipient');
|
||||
assert.match(seen.data, /Subject: \[ScreenTinker\] Hello/, 'subject and its prefix survive encoding');
|
||||
assert.match(seen.data, /From: ScreenTinker <noreply@example\.com>/, 'display-name form still renders');
|
||||
assert.match(seen.data, /Content-Type: multipart\/alternative/, 'text and html sent as alternatives');
|
||||
|
||||
// The string `from` form, used when there is no override.
|
||||
await transporter.sendMail({
|
||||
from: 'ScreenTinker <noreply@example.com>', to: 'user@x.com', subject: 'Welcome', html: '<p>x</p>',
|
||||
});
|
||||
transporter.close();
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSmtpMessage output is something the installed nodemailer can send', async () => {
|
||||
const { srv, port, seen } = await startFakeSmtp();
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
|
||||
});
|
||||
// Built by our own code rather than hand-written here, so the two cannot drift apart.
|
||||
await transporter.sendMail(buildSmtpMessage('to@x.com', 'Subj', 'plain', '<p>rich</p>', 'Sender Name'));
|
||||
assert.ok(seen.commands.some(c => /^RCPT TO:<to@x\.com>/i.test(c)), 'recipient reached the wire');
|
||||
assert.match(seen.data, /Subject: Subj/, 'subject reached the wire');
|
||||
transporter.close();
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
94
server/test/telemetry-collector.test.js
Normal file
94
server/test/telemetry-collector.test.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use strict';
|
||||
|
||||
// The collector and the public aggregate it feeds. Both were previously inline in server.js
|
||||
// with no test at all — the endpoint that decides what a public marketing page claims, and the
|
||||
// unauthenticated one anyone on the internet can POST to.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const express = require('express');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE telemetry_reports (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT,
|
||||
screen_count INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);`);
|
||||
|
||||
const app = express();
|
||||
app.use('/api', require('../routes/telemetry-collector')(db));
|
||||
const server = app.listen(0);
|
||||
let base;
|
||||
|
||||
test.before(async () => {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
test.after(() => server.close());
|
||||
|
||||
const uuid = (n) => `0000000${n}-0000-4000-8000-00000000000${n}`.slice(0, 36).padEnd(36, '0');
|
||||
const report = (body) => fetch(`${base}/api/telemetry/report`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
test('a well-formed report is accepted and stored', async () => {
|
||||
const res = await report({ instance_id: uuid(1), version: '1.9.34', screen_count: 12 });
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), { ok: true });
|
||||
const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1));
|
||||
assert.equal(row.screen_count, 12);
|
||||
assert.equal(row.version, '1.9.34');
|
||||
});
|
||||
|
||||
test('reporting again updates the row rather than adding one', async () => {
|
||||
// An install reports daily. If this ever inserted instead of updating, one install would
|
||||
// occupy 365 rows a year and the public figure would count it 365 times.
|
||||
await report({ instance_id: uuid(1), version: '1.9.35', screen_count: 20 });
|
||||
const n = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports WHERE instance_id = ?').get(uuid(1)).c;
|
||||
assert.equal(n, 1, 'still a single row for this install');
|
||||
const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1));
|
||||
assert.equal(row.screen_count, 20, 'count is the latest reported');
|
||||
assert.equal(row.version, '1.9.35');
|
||||
});
|
||||
|
||||
test('hostile or malformed bodies are refused, not stored', async () => {
|
||||
const before = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c;
|
||||
const bad = [
|
||||
{ instance_id: 'not-a-uuid', screen_count: 1 },
|
||||
{ instance_id: uuid(2) }, // no count
|
||||
{ instance_id: uuid(2), screen_count: -1 },
|
||||
{ instance_id: uuid(2), screen_count: 1e9 }, // absurd, would skew the total
|
||||
{ instance_id: uuid(2), screen_count: 1.5 }, // not an integer
|
||||
{ instance_id: uuid(2), screen_count: 1, version: 'v'.repeat(41) },
|
||||
{},
|
||||
];
|
||||
for (const body of bad) {
|
||||
const res = await report(body);
|
||||
assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
|
||||
}
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c, before,
|
||||
'nothing rejected reached the table');
|
||||
});
|
||||
|
||||
test('the public aggregate sums screens across installs and names no one', async () => {
|
||||
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
|
||||
VALUES (?,?,?,?,?)`).run(uuid(3), '1.9.34', 480, 1, 1);
|
||||
const res = await fetch(`${base}/api/public/stats`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.screens, 500, '20 + 480');
|
||||
assert.equal(body.installs, 2);
|
||||
// The whole payload — no instance ids, no versions, nothing per-install.
|
||||
assert.deepEqual(Object.keys(body).sort(), ['installs', 'screens']);
|
||||
assert.match(res.headers.get('cache-control') || '', /max-age=300/);
|
||||
});
|
||||
|
||||
test('the aggregate is cached, so a scraper cannot turn page views into queries', async () => {
|
||||
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
|
||||
VALUES (?,?,?,?,?)`).run(uuid(4), '1.9.34', 999, 1, 1);
|
||||
const body = await (await fetch(`${base}/api/public/stats`)).json();
|
||||
assert.equal(body.screens, 500, 'still the cached figure, not 1499');
|
||||
});
|
||||
232
server/test/telemetry.test.js
Normal file
232
server/test/telemetry.test.js
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
'use strict';
|
||||
|
||||
// Opt-in install statistics. The promises this feature makes are all negative ones — it does not
|
||||
// send until asked, it does not send more than three fields, it does not ask twice — and a
|
||||
// negative promise is exactly the kind that rots silently. These bites pin each one.
|
||||
|
||||
const { test, after, mock } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { db } = require('../db/database');
|
||||
const appSettings = require('../lib/app-settings');
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
after(() => { telemetry.stop(); fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
function reset() {
|
||||
db.prepare('DELETE FROM app_settings').run();
|
||||
appSettings.__reload();
|
||||
}
|
||||
|
||||
test('an install that has not been asked reports nothing', async () => {
|
||||
reset();
|
||||
assert.equal(telemetry.state(), 'unasked');
|
||||
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
|
||||
try {
|
||||
const r = await telemetry.report(db);
|
||||
assert.equal(r.sent, false); assert.equal(r.reason, 'not_enabled');
|
||||
assert.equal(spy.mock.callCount(), 0, 'no outbound request may be made before consent');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('declining is remembered, so the prompt does not return after an update', () => {
|
||||
reset();
|
||||
telemetry.setEnabled(false);
|
||||
assert.equal(telemetry.state(), 'off', 'a decline must persist as off, never fall back to unasked');
|
||||
appSettings.__reload(); // survives a restart
|
||||
assert.equal(telemetry.state(), 'off');
|
||||
});
|
||||
|
||||
test('a declined install still reports nothing', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(false);
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
|
||||
try {
|
||||
const d = await telemetry.report(db); assert.equal(d.sent, false); assert.equal(d.reason, 'not_enabled');
|
||||
assert.equal(spy.mock.callCount(), 0);
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('the payload is exactly three fields, and no more', async () => {
|
||||
reset();
|
||||
const body = telemetry.payload(db);
|
||||
assert.deepEqual(Object.keys(body).sort(), ['instance_id', 'screen_count', 'version'],
|
||||
'adding a field here is a privacy decision, not a refactor — it must fail this test first');
|
||||
assert.match(body.instance_id, /^[0-9a-f-]{36}$/i);
|
||||
assert.equal(typeof body.version, 'string');
|
||||
assert.equal(typeof body.screen_count, 'number');
|
||||
});
|
||||
|
||||
test('the instance id is stable across reads and restarts', () => {
|
||||
reset();
|
||||
const first = telemetry.instanceId();
|
||||
assert.equal(telemetry.instanceId(), first, 'must not mint a new id per call');
|
||||
appSettings.__reload();
|
||||
assert.equal(telemetry.instanceId(), first, 'must survive a restart, or every install counts twice');
|
||||
});
|
||||
|
||||
test('screen_count counts paired displays, not provisioning rows', () => {
|
||||
reset();
|
||||
db.prepare('DELETE FROM devices').run();
|
||||
const ins = db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES (?, ?, ?, ?, 'offline')");
|
||||
ins.run('d1', 'One', '111111', 'tok1');
|
||||
ins.run('d2', 'Two', '222222', 'tok2');
|
||||
// Never paired: a provisioning row nobody connected is not a deployed screen.
|
||||
db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES ('d3','Three','333333',NULL,'offline')").run();
|
||||
|
||||
assert.equal(telemetry.payload(db).screen_count, 2);
|
||||
db.prepare('DELETE FROM devices').run();
|
||||
});
|
||||
|
||||
test('when enabled it sends exactly the payload, and records what it sent', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
|
||||
let seen = null;
|
||||
const spy = mock.method(globalThis, 'fetch', async (url, opts) => {
|
||||
seen = { url, body: JSON.parse(opts.body), method: opts.method };
|
||||
return { ok: true, status: 200 };
|
||||
});
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(r.sent, true);
|
||||
assert.equal(seen.method, 'POST');
|
||||
assert.equal(seen.url, 'https://example.test/report');
|
||||
assert.deepEqual(Object.keys(seen.body).sort(), ['instance_id', 'screen_count', 'version'],
|
||||
'the bytes on the wire must match the audited payload, not a superset');
|
||||
|
||||
// An operator can check rather than trust: what was sent is retrievable verbatim.
|
||||
const last = telemetry.getLastReport();
|
||||
assert.deepEqual(last.body, seen.body);
|
||||
assert.equal(typeof last.at, 'number');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('a blocked outbound connection is recorded, with the address that was blocked', async () => {
|
||||
// Egress filtering is the normal failure on a self-hosted box and is otherwise invisible: the
|
||||
// operator sees nothing arriving and cannot tell a firewall from a broken feature. The UI can
|
||||
// only name the host to allowlist if the failure is recorded here.
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
||||
try {
|
||||
await telemetry.report(db, { urls: [{ url: 'https://stats.example.test/report', kind: 'screentinker' }] });
|
||||
const err = telemetry.getLastError();
|
||||
assert.ok(err, 'a failed attempt must be recorded, or the operator has nothing to act on');
|
||||
assert.equal(err.reason, 'network');
|
||||
assert.equal(err.url, 'https://stats.example.test/report', 'must record the address actually tried');
|
||||
assert.equal(typeof err.at, 'number');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('a later success clears the stale failure', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const bad = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
||||
try { await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] }); } finally { bad.mock.restore(); }
|
||||
assert.ok(telemetry.getLastError(), 'precondition: a failure was recorded');
|
||||
|
||||
const good = mock.method(globalThis, 'fetch', async () => ({ ok: true, status: 200 }));
|
||||
try {
|
||||
await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(telemetry.getLastError(), null,
|
||||
'a stale firewall warning must not outlive the problem it describes');
|
||||
} finally { good.mock.restore(); }
|
||||
});
|
||||
|
||||
test('an operator collector is ADDITIONAL — it never replaces the shared report', async () => {
|
||||
// The whole point of naming it EXTRA rather than ENDPOINT: configuring your own collector must
|
||||
// not silently redirect the report the operator agreed to share. If this ever becomes a
|
||||
// redirect, the opt-in stops meaning what the UI says it means.
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
|
||||
try {
|
||||
const dests = telemetry.destinations();
|
||||
assert.equal(dests.length, 2, 'sharing on + own collector = both, never one');
|
||||
assert.deepEqual(dests.map(d => d.kind).sort(), ['extra', 'screentinker']);
|
||||
|
||||
const hits = [];
|
||||
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
|
||||
try {
|
||||
await telemetry.report(db);
|
||||
assert.equal(hits.length, 2, 'both destinations must receive the report');
|
||||
assert.ok(hits.includes('https://mine.example.test/collect'));
|
||||
assert.ok(hits.some(u => u.includes('screentinker.com')), 'the shared report must still be sent');
|
||||
} finally { spy.mock.restore(); }
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('an operator can keep their own statistics while sharing nothing with us', async () => {
|
||||
// Someone who wants internal fleet numbers but nothing leaving for us sets their own collector
|
||||
// and leaves sharing off. Supported on purpose: it is their server posting to their host.
|
||||
reset();
|
||||
telemetry.setEnabled(false);
|
||||
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
|
||||
try {
|
||||
const hits = [];
|
||||
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
|
||||
try {
|
||||
await telemetry.report(db);
|
||||
assert.deepEqual(hits, ['https://mine.example.test/collect']);
|
||||
assert.ok(!hits.some(u => u.includes('screentinker.com')),
|
||||
'sharing is off — nothing may reach us, whatever else is configured');
|
||||
} finally { spy.mock.restore(); }
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('one unreachable destination does not stop the other', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const spy = mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (url.includes('broken')) throw new Error('ECONNREFUSED');
|
||||
return { ok: true, status: 200 };
|
||||
});
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [
|
||||
{ url: 'https://broken.example.test/a', kind: 'extra' },
|
||||
{ url: 'https://working.example.test/b', kind: 'screentinker' },
|
||||
] });
|
||||
assert.equal(r.results.filter(x => x.sent).length, 1, 'the reachable one still receives it');
|
||||
assert.equal(r.results.filter(x => !x.sent).length, 1);
|
||||
assert.equal(telemetry.getLastError().url, 'https://broken.example.test/a',
|
||||
'the failure names the destination that actually failed');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('a failed send is quiet and local — never throws, never records a phantom report', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(r.sent, false);
|
||||
assert.equal(r.reason, 'network');
|
||||
assert.equal(telemetry.getLastReport(), null, 'a failed send must not look like a successful one');
|
||||
} finally { spy.mock.restore(); }
|
||||
|
||||
// An HTTP error is likewise not a success.
|
||||
const spy2 = mock.method(globalThis, 'fetch', async () => ({ ok: false, status: 503 }));
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(r.sent, false);
|
||||
assert.equal(r.reason, 'http_503');
|
||||
assert.equal(telemetry.getLastReport(), null);
|
||||
} finally { spy2.mock.restore(); }
|
||||
});
|
||||
68
server/test/version-precedence.test.js
Normal file
68
server/test/version-precedence.test.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Prerelease ordering.
|
||||
*
|
||||
* The bug this pins: a plain string compare put every build from alpha10 onward BELOW alpha8,
|
||||
* because '1' < '8'. The OTA check then answered `client-newer` and refused to offer the update,
|
||||
* so a fleet on alpha8 could not be moved forward — silently, while the server reported the newer
|
||||
* build as `latest` in the same response. Two comparators carried the assumption, each with a
|
||||
* comment saying lexical was fine "for our naming". It was, until the counter passed 9.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { preCmp } = require('../lib/version-precedence');
|
||||
const { cmp } = require('../lib/ota-breaker');
|
||||
const bsUpdate = require('../lib/brightsign-update');
|
||||
|
||||
const sign = (n) => (n === 0 ? 0 : n < 0 ? -1 : 1);
|
||||
|
||||
test('double-digit prereleases outrank single-digit ones', () => {
|
||||
// The exact case that stranded the fleet.
|
||||
assert.equal(sign(preCmp('alpha11', 'alpha8')), 1, 'alpha11 must be newer than alpha8');
|
||||
assert.equal(sign(preCmp('alpha10', 'alpha9')), 1);
|
||||
assert.equal(sign(preCmp('beta12', 'beta2')), 1);
|
||||
assert.equal(sign(preCmp('rc10', 'rc9')), 1);
|
||||
// And the reverse still holds, so nothing was merely inverted.
|
||||
assert.equal(sign(preCmp('alpha2', 'alpha10')), -1);
|
||||
});
|
||||
|
||||
test('ordinary alphabetical precedence is unchanged', () => {
|
||||
assert.equal(sign(preCmp('beta1', 'alpha11')), 1, 'beta outranks alpha regardless of number');
|
||||
assert.equal(sign(preCmp('rc1', 'beta9')), 1, 'rc outranks beta');
|
||||
assert.equal(sign(preCmp('alpha8', 'alpha8')), 0);
|
||||
});
|
||||
|
||||
test('semver dot form works too, so the naming can move without another fix', () => {
|
||||
assert.equal(sign(preCmp('alpha.11', 'alpha.8')), 1);
|
||||
assert.equal(sign(preCmp('alpha', 'alpha.1')), -1, 'fewer identifiers = lower precedence');
|
||||
assert.equal(sign(preCmp('alpha.1', 'beta.1')), -1);
|
||||
});
|
||||
|
||||
test('OTA: a device on alpha8 is offered alpha11', () => {
|
||||
// Through the real comparator the update check uses, not just the helper.
|
||||
assert.equal(cmp('1.9.34-alpha11', '1.9.34-alpha8'), 1);
|
||||
assert.equal(cmp('1.9.34-alpha10', '1.9.34-alpha6'), 1);
|
||||
// A release still outranks any prerelease of the same core.
|
||||
assert.equal(cmp('1.9.34', '1.9.34-alpha11'), 1);
|
||||
// And a newer core still wins outright, whatever the prerelease says.
|
||||
assert.equal(cmp('1.9.35-alpha1', '1.9.34-alpha11'), 1);
|
||||
});
|
||||
|
||||
test('OTA decide(): alpha8 -> alpha11 is an offer, not client-newer', () => {
|
||||
// The end-to-end symptom: the endpoint reported the newer build as `latest` and refused it
|
||||
// in the same breath.
|
||||
const { decide } = require('../lib/ota-breaker');
|
||||
const d = decide('1.9.34-alpha8', '1.9.34-alpha11', 'test-device-precedence');
|
||||
assert.equal(d.update_available, true, `expected an offer, got ${d.reason}`);
|
||||
assert.notEqual(d.reason, 'client-newer');
|
||||
});
|
||||
|
||||
test('BrightSign host packages order the same way', () => {
|
||||
// Same assumption lived here, with the same comment. A BrightSign package update that goes
|
||||
// wrong replaces the script that boots the player, so wrong-way ordering matters more here.
|
||||
assert.equal(sign(bsUpdate.compareVersions('1.9.34-rc10', '1.9.34-rc9')), 1);
|
||||
assert.equal(sign(bsUpdate.compareVersions('1.9.34', '1.9.34-rc10')), 1);
|
||||
assert.equal(sign(bsUpdate.compareVersions('1.9.34-alpha2', '1.9.34-alpha10')), -1);
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
|
||||
id="http://screentinker.com/player" version="1.9.34" viewmodes="maximized">
|
||||
id="http://screentinker.com/player" version="1.9.36" viewmodes="maximized">
|
||||
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
|
||||
<tizen:profile name="tv"/>
|
||||
<name>ScreenTinker</name>
|
||||
|
|
|
|||
Loading…
Reference in a new issue