mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
9 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1ec32197b2 |
Let a BrightSign host COLLECT its capture request over HTTP
Server side of the inverted capture path. The host half is not here — see the end of this message. Every other player is TOLD to capture: the server emits device:screenshot-request over the device socket and the page photographs itself. A BrightSign cannot photograph itself. Video decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the content missing — which is why that platform has been answering screenshot requests with a card explaining that the video is uncapturable. Only the host, through the player's own DWS, can get a real frame. The obvious way to ask the host is through the page, and it does not work. On an XT245 (BOS 9.1.93.2) page->host messaging is dead after load: instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all while the page was posting, though the boot-time probe round-trips. The registry is not an alternative either — a running BrightScript does not observe registry writes made by anyone else, proven by writing the key externally through the DWS and watching the host ignore it. What the host CAN do is HTTP; it already fetches its own package updates that way. So the direction is inverted: the request waits here and the host collects it. The image comes back over a plain POST, which means a capture will work even when the page is wedged — exactly when an operator most wants to see the screen. Held in memory on purpose. A capture request is worthless a minute after it was made — someone clicked a button and is watching for the result — so persisting it would only add a way to deliver a stale screenshot after a restart. Bounded and TTL'd so a fleet going offline mid-request cannot grow it, and a repeat request REPLACES rather than queues so a 1fps stream builds no backlog. Authenticated with the same device_id + device_token pair the socket uses. /api/brightsign/package is public because a player fetches it before it has any identity; a screenshot is a picture of a customer's screen and belongs to one display. deviceSocket now exposes ONE ingestScreenshot() used by both the socket handler and the HTTP route, so a BrightSign screenshot reaches the dashboard by exactly the route every other player's does rather than becoming a second, subtly different feature. Note those exports must be attached AFTER `module.exports = function setupDeviceSocket`, which reassigns the object — attaching above it silently wipes them, which cost a debugging round. NOT INCLUDED, deliberately: the host-side poll. Adding it to autorun.brs's main loop kills the BrightScript script within seconds of boot — the page keeps playing, because the widget outlives the script, so from the dashboard it looks healthy. Cause unidentified; BrightScript runtime faults do not reach /api/v1/logs, so there is no error text to read. Half a feature that silently takes down the host is worse than none, so the server waits for a host that can safely ask. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS |
||
|
|
3e37d33b80 |
QA: close four ways a control or an asset lied about itself
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.
1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
sets before it knows the file exists. Every player downloader treats 200 as success,
so a panel stores the HTML page AS the video and caches it for a month, rendering a
black frame with nothing in any log. Reachable exactly when it hurts: a content
replace writes a new random filename and unlinks the old one. The mount now
terminates a miss with a 404 and drops the cache header.
2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
has always refused a command the panel cannot honour, and the comment above it is right
about why ("hiding the button is not enforcement — this socket is reachable directly").
Every word applied to the four handlers immediately above it, which had none: a display
declaring [] still received screenshot-request, remote-touch, remote-key and
remote-start. Measured, not inferred. They now refuse on remote.screenshot /
remote.input / remote.stream and name the capability in the ack; remote-stop stays
ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
absent declaration still resolves to its platform baseline and keeps everything.
The wall panel list (#235) made this visible: it offered a Screenshot button for every
panel, including a BrightSign, which has no screenshot capability at all, and popped a
toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
as "pre-capability server, show everything" — wrong in the one case that matters), so
the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
commands on display.power; both now agree.
3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
field to the literal 'unknown' and persistIdentity wrote it straight over. That column
is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
power and reboot and gained screenshots it cannot take. platform and client_type are
now preserved (physical facts); client_version and contract_version still decay, because
there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
second signal for a Tizen TV.
4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
brand-new "default an item to the clip's own length" then handed out the wrong number
for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
image measured it with raw sharp metadata and thumbnailed without .rotate(),
re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
share lib/content-ingest.deriveMediaMetadata.
Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.
Full suite green (1319).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
0082191f9b |
Show only the controls a display can actually honour
Every device control was offered to every display. A browser tab was shown "Reboot device", a Tizen TV was shown screen power, a player with no framebuffer read was shown a live view that stayed black. They all looked like working buttons and did nothing — the "reports success and changes nothing" shape that keeps costing people days. Players now declare what they can do at registration, because only the player knows at runtime: an Android panel gains real screenshots when accessibility is switched on and loses Tier-2 when device owner is revoked. The dashboard hides what is not supported rather than disabling it, and the Info tab lists the capability set so a missing control is explainable. The declaration is three-state and the middle state is load bearing: NULL means "has never told us anything" and falls back to a per-platform baseline, because several hundred displays in the field will not update before this deploys and blanking their controls would be a far worse bug. An empty array means "I genuinely can do nothing" and is honoured. Hiding a button is not enforcement, so unsupported commands are also refused server-side — the socket is reachable directly and a stale tab still renders the old controls. Group sends report skipped devices separately from sent ones; counting an unreachable member as "sent" is how an operator walks away believing the whole group rebooted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
c4b5a8679e |
refactor(auth): centralise session token resolution across manual verify sites
Six places verified a session JWT inline instead of going through requireAuth, each repeating a slightly different subset of its checks. Introduce resolveSessionUser() in middleware/auth.js as the single definition of "this token is a usable session, and here is whose it is", and route all of them through it: the three /api/status token routes, the screenshot route, the content-reference gate, and the /dashboard socket handshake. requireAuth is now a thin wrapper over the same helper, so the two cannot drift. Also: - Give the pre-TOTP token a distinct audience so it is redeemable only through verifyMfaPendingToken (POST /api/auth/totp/verify). verifyToken refuses any token carrying an audience, so a token minted for one purpose cannot be redeemed on another path. - The dashboard socket handshake now takes userId/userRole from the live users row rather than from the token claim, so role changes take effect on the next connection instead of riding the token's remaining lifetime. - Add test/session-token-resolution.test.js covering all six surfaces, including the socket handshake. Every call site keeps the status code and error body it returned before. Net query cost: the content-reference gate and the socket handshake each gain one users-by-id lookup (the same one requireAuth already does per request); the other four are unchanged or replace an equivalent lookup. In-flight pre-TOTP tokens are invalidated by the audience change; they live 5 minutes, so the window is a re-login at worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
cbf81a05a3 |
fix(#146): crash-hardening — one device's handler throw can't take down the fleet
Found in the alpha load test: client-chosen pairing codes collide by birthday paradox, the provisioning INSERT hit UNIQUE(devices.pairing_code), the SqliteError threw out of the (synchronous) socket handler -> uncaughtException -> logFatalAndExit -> the WHOLE server exited and every device dropped. The colliding flood crash-LOOPED the container (2 restarts). Two layers, same "one device can't take down the fleet" theme as #142/#143/#144: 1. Narrow (deviceSocket.js): wrap the device:register provisioning INSERT in try/catch — a UNIQUE pairing_code collision (or ANY db error) rejects THAT registration (device:auth-error -> client retries) instead of throwing. currentDeviceId/authenticated now set only AFTER the row exists (no half-auth socket on failure). 2. Broader (lib/safe-socket.js): protectSocket() overrides socket.on per connection so any handler throw is caught, logged (event + id + stack), the socket told, and DISCONNECTED — per-CONNECTION fail-fast, not whole-PROCESS. We don't keep serving a connection from possibly-half-mutated state (honors the existing fail-fast intent), we just contain it to "one device reconnects" (a non-event after beta5). Wired into both the /device and /dashboard connection handlers; auto-covers future handlers. Audited first: no handler throws as control flow, so blanket-wrapping is safe. Tests (mutation-verified, fail without their fix): - register-insert-crash.test.js: a pairing_code collision AND a general bind error each reject-one-device with no uncaughtException; server keeps serving. - socket-handler-isolation.test.js: a throwing handler disconnects only that socket; the server + other sockets stay alive. Full suite 243/243. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f5ca26ae2d |
fix(socket): offline debounce + truthful single-device command feedback
Two dashboard-accuracy improvements for issue #3. Disconnect debounce (5s): - Brief transient flaps (Engine.IO ping miss, eviction-then-reconnect, Wi-Fi blip) no longer immediately flip the device to offline in the dashboard. Disconnect handler now defers the offline transition; register handlers cancel the pending timer if reconnect lands in window. - Existing stale-disconnect guard kept as fast-path for the eviction case (no timer scheduled at all when the active heartbeat conn is already a different socket). - Re-check at timer fire compares socketIds: aborts only if a GENUINELY DIFFERENT socket reclaimed the device. Just the closing socket's own (not-yet-cleaned-up) entry is treated as stale and proceeds with offline transition. - Server-restart mid-grace is handled by the heartbeat checker safety net (existing component): any 'online' row with last_heartbeat older than heartbeatTimeout gets marked offline on next sweep. Truthful single-device command feedback: - dashboard:device-command handler now checks deviceNs.adapter.rooms for an active socket before emitting (matches the group-command route's pattern). - If room is empty, falls through to commandQueue.queueCommand (lazy require - if commit C is reverted, MODULE_NOT_FOUND is cached and every subsequent call gets consistent queued=false behavior). - Returns three-state ack to caller: { delivered, queued, reason }. - Server log line was misleading - now logs 'Command delivered to device X' vs 'Command for offline device X (queued=true/false)'. Frontend: - sendCommand() takes optional callback. Without one, fires-and-forgets (no behavior change for non-wired callers). With one, uses Socket.IO .timeout(5000).emit so the callback always fires (ack or no_ack). - Six device-detail command buttons wired to three-state toasts: reboot, shutdown, screen_off, screen_on, launch, update. - delivered: green/success toast (existing localized message) - queued: amber/warning toast (new generic message) - no_ack: red/error toast - fallback: red/error toast - Two callers intentionally left fire-and-forget: - window._sendCmd (generic remote-overlay keypress/touch helper) - enable_system_capture (has its own visual state machine; out of scope for this commit) Three new i18n keys (en.js only; other locales follow later): - device.toast.command_queued - device.toast.command_undeliverable - device.toast.command_no_ack Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fc29843035 |
feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename.
Fix: at connect, enumerate the user's accessible workspace_ids (direct workspace_members + org_owner/admin paths + platform_admin 'all') via new accessibleWorkspaceIds() helper in lib/tenancy.js; socket.join one room per workspace. All 12 dashboardNs.emit sites across deviceSocket / heartbeat / server.js / devices route / video-walls route now route via dashboardNs.to(workspaceRoom(...)).emit() with the workspace looked up from the relevant device or wall. New lib/socket-rooms.js holds the helpers and breaks a circular dependency (dashboardSocket already requires heartbeat, so heartbeat can't require dashboardSocket). Inbound 6 commands rewired to canActOnDevice(socket, deviceId, tier): request-screenshot is read tier (workspace_viewer+); remote-touch/key/start/stop and device-command are write tier (workspace_editor+). Platform_admin and org_owner/admin always pass via actingAs. Legacy admin/superadmin branch dropped. Lifecycle note: workspace-switch already calls window.location.reload (Phase 3 switcher), which forces a fresh socket with updated memberships - no per-emit re-evaluation needed. Smoke tested with 3 simultaneous socket.io-client connections (switcher-test, swninja, dw5304 platform_admin) + direct canActOnDevice invocation for 6 user/device/tier combinations. All 9 outbound isolation cells and all 6 permission gates pass. Fixture mutation: switcher-test's Field Crew membership flipped from workspace_editor to workspace_viewer to exercise the read/write tier split in one login. |
||
|
|
1594a9d4a4 |
Initial open source release
ScreenTinker - open source digital signage management software. MIT License, all features included, no license gates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |