Commit graph

9 commits

Author SHA1 Message Date
ScreenTinker 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
2026-08-07 14:15:06 -05:00
ScreenTinker 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
2026-08-06 16:12:29 -05:00
ScreenTinker 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
2026-08-05 14:24:52 -05:00
ScreenTinker 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>
2026-07-24 20:58:05 -05:00
screentinker 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.
2026-07-12 19:41:07 -05:00
ScreenTinker 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>
2026-06-29 23:38:11 -05:00
ScreenTinker 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>
2026-05-14 13:11:40 -05:00
ScreenTinker 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.
2026-05-12 11:34:24 -05:00
ScreenTinker 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>
2026-04-08 12:14:53 -05:00