Commit graph

432 commits

Author SHA1 Message Date
Claude 80c8c81fb8 Check that a schedule's zone belongs to the caller's workspace
Creating a schedule validates every reference it carries against the caller's workspace — content,
widget, layout, playlist all go through checkRefInWorkspace. zone_id was the one polymorphic
reference left out of that list, so a schedule could be pointed at a zone belonging to another
workspace's layout.

It needed its own check rather than a sixth entry in the table: layout_zones has no workspace_id
column of its own. A zone belongs to a layout, and the layout carries the workspace, so the
ownership question has to be answered through that join. A zone on a platform-template layout
(workspace_id IS NULL) is allowed, matching how the other references treat templates.

882 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:54:24 -05:00
Claude 4a65c4cec7 Tell the screens when the playlist they are showing is deleted
devices.playlist_id is ON DELETE SET NULL, so the database detached correctly — but the handler
emitted nothing, so a screen kept displaying the deleted playlist until it happened to reconnect or
was restarted. You delete a playlist to take content off the wall; the wall carried on showing it.

Every sibling mutation in this file already pushes (publish, assign), and DELETE
/devices/:id/playlist was given a push for precisely this reason: "so the screen stops, rather than
leaving the old content up until something else happens to update it".

The affected devices are read before the delete, since the association is gone the moment it runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:50:50 -05:00
Claude f4d309a0d4 Apply a playlist change even when the outgoing item never advances
Replacing the single item of a one-item playlist did nothing. The old promo, board or clip kept
playing while the dashboard showed the new playlist published and the device perfectly healthy —
only a reboot or a manual refresh cleared it.

#157 defers a rotation so a live item is not yanked mid-play, and applies it "on the next natural
advance". For a one-item playlist there is no such thing, by design: single-item rendering
deliberately never advances. A video gets `loop = (playlist.length === 1)` and so never fires
`ended`; a YouTube embed loops for the same reason and skips its safety net; a solo widget is "held"
on a self-re-arming refresh that never calls nextItem, because reloading it would reset a directory
board's scroll. Tizen is worse still — `single` makes every renderer skip its timer, so images
freeze too.

Two guards, the same pair already applied to the Android controller:

- A one-item playlist is never deferred. There is nothing to protect from being cut off, since
  nothing was going to advance anyway.
- Any deferral that does happen gets a 60-second deadline. The deferral is a bet that an advance is
  coming; if the bet loses, the change must still land rather than strand the screen on content the
  operator has already replaced.

Verified in headless Chrome: a one-item playlist holding a solo widget (the "held" case that never
advances), its only item replaced with a different widget — the screen followed, with no reload and
no restart. Before the change it stayed on the replaced item indefinitely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:17:44 -05:00
Claude d3f6af831b Recover a zone whose video fails, instead of leaving that region black
In a multi-zone layout a zone's video advanced only on `ended`. On the web there was no error
handler and — alone among the zone branches, which all arm a timer — no timer either. On Android the
zone player listened for STATE_ENDED with no error listener and no fallback.

A playback error lands in STATE_IDLE, never STATE_ENDED, so nothing advanced. A 404, an unreachable
remote_url, a clip the device cannot decode, or content not yet cached while the device is offline
(the zone then falls back to the server URL, which fails with no network) all had the same result:
that region of the screen went black and stayed black for days, while every other zone kept rotating
normally. It reads as a rendering bug rather than a bad file, and nothing self-heals — the layout has
to change or the app has to restart.

Both fixes already existed elsewhere and were simply not carried across. MediaPlayerManager treats a
playback error as a completion for exactly this reason ("Root-2: a corrupt/undecodable video used to
freeze the playlist forever"), the fullscreen web path has both an onerror and a timer, and Tizen's
ZoneRenderer has an onerror plus a duration+5s safety net. The multi-zone paths were the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:11:59 -05:00
Claude a3b668d32f Treat an empty device_info as "nothing new", not as "forget what you know"
Every web and BrightSign player nulled seventeen of its own device columns every five minutes.

The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has nothing
new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is a blind
full-row overwrite with no per-field presence check, so it bound undefined for every column.
better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and the row was
quietly emptied: android_version, app_version, screen_width/height, render_*, ota_status and
attempts, tier, the four capability flags and the four volume/brightness columns.

Android never hit it, because it always sends the full object. So this degraded exactly the client
family that cannot be inspected any other way — a browser player has no adb, and the dashboard row
is all there is. Fleet view, resolution diagnostics and any version-based logic read blank for them,
which also makes evaluating a browser-based platform look worse than it is.

The surrounding code already anticipates the refresh shape: recordReconnect and persistIdentity are
both gated behind `if (!isPlaylistRefresh)`. This call was the one that was not.

5 tests, including one pinning the driver behaviour the bug depended on — undefined binds as NULL
rather than throwing, which is why this was a silent five-minutely wipe instead of a loud error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:09:11 -05:00
Claude fb8cafc444 Web player: survive the suspended-account card destroying the status element
The suspended branch replaces the whole status overlay with its own markup, and that markup does not
contain #statusText. showStatus then did:

    document.getElementById('statusText').textContent = msg;

so every later call threw a TypeError for the life of the page. The consequences got worse the
further down they went:

- Each refresh beat re-emits device:paired, whose handler calls showStatus('Waiting for content...')
  — so the player raised an uncaught error and sent itself a "crashed" exit beacon every few minutes
  while suspended. This is very likely the "Cannot set properties of null (setting 'textContent')"
  the comment near the exit-signal contract says could never be traced.
- showNothingScheduled() calls showStatus BEFORE arming its 30-second re-check. So once the account
  was restored, a playlist whose dayparts had all closed left the screen on the stale orange
  "Account Suspended / Please upgrade your plan" card with no retry timer at all — it never
  re-checked the schedule and never recovered without a reload.

showStatus now rebuilds the element if it is missing rather than bailing, so the message the caller
asked for is actually displayed and the recovery path continues.

Verified in headless Chrome against the real player: destroy the overlay exactly as the suspended
branch does, then call showStatus — no throw, no uncaught page error, and "Waiting for content..."
on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:08:06 -05:00
Claude 64a6bfd860 Web player: notice when the layout changes, not just when the items do
Editing a layout did nothing on a web screen that was already showing one. Add a zone, move an item
between zones, resize a zone, switch layouts, clear the layout — all silent, for as long as the item
list itself stayed the same.

Two reasons, and both had to be fixed:

- The change fingerprint covered item identity, order, revision, schedules and transition, but not
  zone_id — so moving an item from one zone to another produced a byte-identical fingerprint
  (published_snapshot is ordered by sort_order, so the order did not move either).
- The layout is not part of the item list at all, so a change to it could never appear in an
  item-derived fingerprint. `layout` was assigned and then the function returned "Playlist
  unchanged", and in multi-zone mode nothing else re-renders: each zone runs its own timers and
  renderContent is never called again. The no-change health check does not help either, because the
  old zone divs still hold media so the surface looks attached.

zone_id now sits in the item fingerprint, and the layout gets its own signature covering the layout
id and every zone's geometry, stacking, type and fit. Tizen's ZoneRenderer has always compared a
zone signature — this is the web equivalent, and it is the same defect that was fixed on Android
this week.

Verified in headless Chrome against the real player: a third zone added IN PLACE (same layout id,
same item list, no reload, no restart) re-rendered the screen to three zones. Before the change that
update was discarded as unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:06:23 -05:00
Claude 9c6b80c411 Apply a saved device snapshot only inside the workspace it was taken in
Per-device settings are saved against the hardware fingerprint so a panel that is deleted and paired
again comes back configured — name, orientation, playlist, blocked flag — without anyone visiting
it. That is deliberate and worth keeping.

A fingerprint is hardware-derived, so the same physical panel presents the same one whoever pairs
it. applyToDevice looked the snapshot up on fingerprint alone with no workspace comparison, and its
per-field guards only check that the referenced row still EXISTS, never who it belongs to:

    if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))

So a screen removed from one workspace and paired into another inherited the first workspace's
playlist and displayed its content, and `blocked` crossed the same way — a device arriving blocked
with nothing the new owner could see to explain it. The manual restore route already compares
workspaces before calling this, so the automatic re-pair path was the only place the check was
missing.

A mismatch is a quiet no-op rather than an error: re-pairing a second-hand panel into a different
workspace is a legitimate thing to do, it just must not carry the previous configuration along. A
snapshot with no workspace recorded still applies, so rows predating the column keep working.

5 tests: neither playlist nor block crosses, a mismatch does not throw, restore still works in full
inside the owning workspace (including a genuine block surviving a re-pair), and legacy rows are
unaffected. 882 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:56:59 -05:00
Claude 14367af5f1 Keep a workspace on schedules that outlive their device group
Deleting a device group converts its group schedules into per-device ones so the screens keep their
programming. That INSERT omitted workspace_id, which is nullable with no default, so every converted
row landed with workspace_id = NULL.

A null workspace does not merely look untidy — it makes the row unreachable in three directions at
once, and they compound into the worst possible combination:

  invisible   the schedule list and the all-screens calendar both filter on workspace_id
  undeletable PUT and DELETE refuse a row with no workspace (403)
  still live  services/scheduler.js has no workspace filter, so it keeps firing every 60 seconds

"I deleted the group but the screens still switch content at 9am, and there is nothing in the
calendar to remove." The only way out was direct database access.

The conversion now carries the workspace, preferring the schedule's own and falling back to the
group's so a legacy group schedule that itself predates workspace_id still converts into a reachable
row. A boot migration repairs rows already orphaned in the field by recovering the workspace from
the device each one targets; anything still unresolvable is left alone rather than guessed at.

4 tests: the converted row keeps its workspace, is visible to the query the list and calendar use,
preserves the actual programming rather than just the ownership, and the repair recovers a row
orphaned before this fix existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:53:21 -05:00
Claude 9958c7c7be Save a layout by diffing its zones, not by deleting and re-inserting them
Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data across the
whole workspace, and returned 200.

The handler deleted every zone and re-inserted the same ids. Its comment claimed that was safe —
"Reuse each zone's id when supplied so device->zone assignments survive an edit (a fresh uuid per
save would orphan them)" — but reusing the id does not help, because SQLite runs the referential
actions on the DELETE and re-inserting the same primary key afterwards resurrects nothing. Two
things point at those rows:

  playlist_items.zone_id  ON DELETE SET NULL  -> every multi-zone playlist item un-assigned, so
                                                 those playlists silently fell back to fullscreen
  schedules.zone_id       ON DELETE CASCADE   -> every zone-bound schedule permanently deleted

No warning, no undo, and nothing in the UI to suggest a geometry tweak had touched schedules at all.

Zones are now updated in place, inserted when new, and deleted only when the editor actually removed
them. An update touches no foreign key, so nothing pointing at a surviving zone is affected. The
cascades are left exactly as they are: on a genuinely removed zone they are the correct behaviour,
and the tests pin that too.

4 tests: a moved zone keeps item assignments and zone-bound schedules, the geometry change is really
applied, adding a zone disturbs nothing, and removing a zone still un-assigns its items and removes
its schedules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:51:28 -05:00
Claude c393cf8ab3 Hold overlay pushes to the same write check as every other fleet action
A PiP overlay renders across a live screen — an arbitrary web page, at full resolution, for as long
as the operator wants. That is a fleet-affecting write, but the three routes that perform it carried
only requireScope('full'), which gates API tokens and is a deliberate pass-through for dashboard
sessions. The file's own comment says so ("No-op for JWT sessions"), on the assumption that
something else covered that case. Nothing did.

Every sibling route pairs the two checks — device-groups.js gates POST /:id/command with
`requireScope('full'), requireGroupWrite`. These had only the half that does nothing for a logged-in
user, so a member who is refused on every other device mutation was accepted here.

requireFleetWrite restores the pairing on POST /, POST /clear and DELETE /, resolving the caller's
context against the workspace the same way the rest of the codebase does.

5 tests pin both directions: refused for a read-only member on all three routes and for an
unauthenticated caller, still allowed for a workspace_editor and for an org owner acting into the
workspace (actingAs, whose workspaceRole is null and must not read as a viewer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:46:03 -05:00
Claude 81f5d4f9f3 Stop shrinking hand-written text widgets into illegibility
A person typing font-size:16px into the Text/HTML widget got 0.15vw — 2.8px on a 1080p screen,
1.9px at 1280 wide, smaller again on anything narrower. Not clipped, not hidden: rendered at a size
nobody can read, in the one widget whose entire purpose is hand-written HTML.

renderText converted every px font size to vw (px/108). That conversion exists to rescue LEGACY
Content Designer output, which used to publish absolute sizes as fontSize*10.8 px — dividing by 108
recovers the author's intended size and lets those widgets scale to any screen. Today's designer
emits cqw and no px at all (frontend/js/views/designer.js), so the conversion only ever needed to
apply to that legacy output. It was applied to everything.

Now it runs only on designer-authored markup, identified by its absolutely-positioned elements —
the same signal the dashboard already uses to decide whether a text widget can be reopened in the
designer. Hand-written markup keeps its px exactly as typed, and legacy designer widgets are
unchanged.

Found by looking at the screen. The rendered HTML and the widget URL both looked correct in every
check I ran; only a screenshot showed the text was microscopic.

5 tests covering both directions, including that a hand-written absolutely-positioned element
without the designer's left-first shape keeps its px. Verified on an Android screen: a 60px heading
and 24px body now render at their authored sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:28:28 -05:00
Claude e0bdd3b65c Web player: re-render a widget whose content was edited
The signature fix was necessary but not sufficient, and only a browser showed it. The update arrived
and was applied — the console logged "Playlist changed, updating" and playlist[0].widget_rev held the
NEW revision — but the iframe on screen still carried the old one.

Two guards were swallowing it. Continuity keeps a surviving item playing and deliberately does not
re-render ("Just retarget the index pointer - no re-render, no interrupt"), and identity is
content/widget ID, which does not change when a widget is EDITED. So the edited widget counted as
surviving. And the fallback that would eventually notice does not apply either: a solo widget is
deliberately never re-rendered on a timer, because that would reset a directory board's scroll.

Between them the new revision sat in the playlist, unused, indefinitely.

Now a surviving WIDGET whose rev changed is re-rendered through the buffered swap — which builds the
new iframe hidden and reveals it on load, so it is flash-free by design and this costs nothing
visually. Non-widget items and unedited widgets are untouched, so the continuity behaviour that
guard exists for is intact.

Verified in headless Chrome driving the real player: paired, widget assigned, then edited with no
page reload and no restart. rev 1785460578 -> 1785460589 on the live iframe.

Also caught here: my first attempt called renderItem(), which does not exist — the console.log fired
and the exception ate the rest of the handler, which looked exactly like the fix not working. The
function is renderContent(item).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:17:22 -05:00
Claude 5c6e0325b1 Widget edits reach the web and Tizen players too, and a pinned render can be cached offline
Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they
rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update
survives a change check, and both change checks key on IDENTITY:

  web    content_id|widget_id|remote_url|filepath|filename|schedules|transition
  tizen  [content_id, widget_id, remote_url, mime_type, schedules, transition]

A widget's identity does not change when it is edited, so an edit produced an identical signature,
the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both,
alongside schedules and transition, which are there for exactly this reason.

The render URL carries the rev on both players as well. In the zone path the web player was picking
up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every
zone; it now reads the zone assignment's own rev.

Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL
carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL
changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for
a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18).

That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could
never hold, so a display that lost its uplink lost its widgets — while its images and video kept
playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected:
they still get no-store, because without a rev nothing distinguishes one render from the next.

Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 859 server
tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:09:06 -05:00
Claude cad19abee1 Push layout edits to displays, and let a layout be renamed
Editing a layout notified nothing at all — no push to the displays using it — so a zone change
waited for the next heartbeat refresh at best. Combined with the Android rebuild being keyed on the
layout ID (which does not change when you edit a layout in place), that is why adding a fourth zone
took a force-stop to appear. The player-side fix makes the rebuild happen; this makes it prompt.

Renaming: duplicating a template produces "<template> (Copy)" and there was nowhere to change it.
The server has always accepted a name on PUT /layouts/:id; no UI ever sent one. The only name field
in the editor belongs to the selected ZONE, which is easy to mistake for the layout's own — zones
could always be renamed, layouts never could. The heading is now an input and its value rides along
with the Save the user already presses.

Verified on an Android 12 emulator, app left running throughout:
  3-zone layout assigned      -> "Multi-zone layout with 3 zones (was=null)"
  4th zone added in place     -> "Multi-zone layout with 4 zones (layout=a96c39ab, was=a96c39ab)"
The ids match, so the old id-only condition would have skipped the rebuild entirely. Applied ~1s
after the PUT, with no restart and no force-stop.

Also verified the background-audio fix on the same device: 1 started audio player with the video in
the foreground, 0 once another app was brought to the front. (First attempt was invalid — HOME
re-shows this player because it is the default launcher, so it never backgrounds.)

859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:58:21 -05:00
Claude 4cc750ba3a Text widgets: stop losing text off the bottom, and show an edit without an app restart
Two separate faults in the same widget, both reported on #234.

1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document
   with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to
   bottom and disappears. It dont fit."

   The content now gets a wrapper and an overflow mode:
     fit    (default) shrink until it fits — a NO-OP when the content already fits, so it rescues
            widgets that are currently losing text without changing ones that are fine
     scroll pan through it on a loop with a pause at each end, for content genuinely longer than a
            screen where shrinking would make it unreadable
     clip   the old behaviour, kept because a designer-positioned layout may deliberately run past
            the edge and must not be rescaled underneath its author

   Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone
   changes the answer, and fonts arriving late is the classic cause of a fit computed against the
   wrong height.

2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves
   live config, but the player deliberately keeps a widget's WebView while its URL is unchanged
   (re-navigating every duration is a visible flash and destroys widget state — a half-typed
   directory search, scroll position). Editing changes the content, not the id, so the URL never
   changed and the reuse check always hit.

   The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so
   the URL differs exactly when the content differs — and only then, so the anti-flash reuse still
   holds for untouched widgets. The rev is refreshed at send time rather than read from the
   published snapshot, because a widget edit does not republish the playlist. Editing a widget also
   now pushes to the displays showing it, instead of notifying nothing at all.

859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:44:12 -05:00
ScreenTinker 752f39ea43 chore(release): v1.9.27 2026-07-30 19:16:55 -05:00
Claude b44f9d4f03 Serve a beta APK alongside the stable one, and let a display move between them
1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one
APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on
every display. This makes it a real channel.

- apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with
  ota_beta = 1.
- A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. The server cannot
  infer it — stable's version is the server's own constant because the two ship together, and
  reading it from the APK means parsing binary AndroidManifest.xml on the request path. If the
  sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep
  getting stable. Failing closed matters: advertising a version that does not match the bytes served
  is the OTA-loop condition this fleet has been bitten by before.
- The check and the download resolve the channel identically and fall back to stable identically, so
  apk_size always describes the bytes actually delivered. No APK change was needed — the client
  already fetches whatever download_url it is handed, so displays in the field can be moved between
  channels from the dashboard today.

Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary
"never offer a downgrade" rule stranded the display and unticking the box would have been another
silent no-op. The first attempt returned any non-opted-in display running a pre-release — which
broke a #144 test, correctly: that would have dragged every existing pre-release tester back to
stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return
now requires evidence we actually served that display the beta channel (devices.ota_channel_served,
written once on change, not per check). A tester ahead of the server on their own build is left
alone exactly as before.

Documented in the README, including the constraint that makes the switch-back physically possible:
beta builds must carry a versionCode no higher than the stable they branch from, because Android
refuses to install a lower one. Equal numbers install in both directions.

Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta
serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates
the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date /
channel-return in order. 859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:12:46 -05:00
ScreenTinker d70764991e chore(release): v1.9.26 2026-07-30 18:43:35 -05:00
ScreenTinker 234bff795d Merge docs/api-device-network-fields: document device network fields, pin the spec version 2026-07-30 18:38:06 -05:00
Claude 301c76c3f7 Let a display opt in to pre-release builds, so a test build is not reverted under the tester
Handing someone a test build was a trap. A prerelease sorts BELOW its own release — 1.9.25-fix234d
is semver-older than 1.9.25 — so a sideloaded display asked "anything newer?", was correctly told
yes, and updated itself straight back off the build we had asked someone to test. Same versionCode,
so Android installed it without complaint. Silent, and within minutes.

That is what happened on #234: the reporter installed the fix, tested for an evening, and reported
nothing had changed. They were right. Their tablet was running the old code again by then, and I had
told them it was fixed without ever checking what the device reported.

Adds a per-display opt-in (devices.ota_beta, default 0, checkbox next to the OTA toggle). When set,
the display keeps a prerelease of the CURRENT core instead of being pulled back to its release.

Deliberately narrow in one direction and deliberately wide in the other:

- Narrow: it only holds a prerelease of the core already installed. A plain release, a -patchN
  build, an upgrade to a newer core, and a display ahead of the server all behave exactly as before,
  and the flag defaults off so a fleet that never sets it is unaffected.
- Wide: an opted-in display is exempted from the superseded-prerelease guard. That guard would
  otherwise pin a tester on an old test build permanently — an older-core prerelease is never
  offered anything, so they would have to notice and sideload their way out. Writing the test is
  what surfaced that; opting in must never mean never updating again.

9 tests covering both directions, including that shipping a newer release pulls a beta display back
onto the release line. 845 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 18:35:31 -05:00
Claude 5297f091af Let a display's playlist actually be cleared
"No playlist" was an option you could select that did nothing. The picker offered it, and the change
handler opened with `if (!newPlaylistId) return; // Don't allow deselecting for now` — so choosing it
sent no request, changed nothing, and said nothing. The guard was honest about why: there was no way
to do it. PUT /devices/:id has never read playlist_id (200, ignored), and POST /playlists/:id/assign
can only ever set one.

Reported on #234 as "I also selected No playlist ... it still showed the same video". It did, and my
first explanation blamed the playlist-swap deferral. The deferral would have stranded it too — that
is fixed separately and tested — but on this path nothing was ever sent, so the deferral never got
the chance.

DELETE /api/devices/:id/playlist, device-scoped rather than playlist-scoped because there is no
playlist to authorize against when clearing. Ownership goes through checkDeviceOwnership like every
other device mutation, so a viewer and a stranger are refused. Clearing an already-clear display is
a no-op success, since it lives in a dropdown someone can pick twice. The now-empty playlist is
pushed to the device so the screen stops, rather than leaving the old content up until something
else happens to refresh it.

Validated on an Android 12 emulator against the reporter's shape: cleared while a YouTube item was
on screen, zero plays afterwards, device row cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 18:29:18 -05:00
Claude c483ef34dd docs(api): document a device's WAN/LAN addresses and SSID sentinel, and stop the spec version drifting
The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said
version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json,
android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it
had been frozen since the public API landed and integrators were reading a version identity that no
longer existed.

Spec changes:

- info.version -> 1.9.25.
- Device gains its two network addresses, which are easy to confuse and are now described so they
  cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For
  aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as
  reported by the player, which is the one that reaches a panel on site. local_ip is new; both were
  returned by GET /devices and neither was documented.
- Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram,
  cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web
  player does not report what Android does.
- wifi_ssid's "permission" value is called out as a sentinel, not a network name: Android 10+
  withholds the SSID without a location permission ScreenTinker only requests if an operator opts
  in. An integrator who does not know that renders "permission" to an end user as their Wi-Fi name.

Drift prevention, because a wrong version number is silent and nobody re-reads one they trust:

- bump-version.sh now writes the spec version too, anchored to info.version (operation- and
  schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected).
- Three contract tests: the spec version tracks package.json, the two addresses stay documented
  and distinct, and the SSID sentinel stays explained.

No new endpoints — audited every public router's routes against the spec and all are documented.
830 server tests + the 5 contract tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:26:47 -05:00
ScreenTinker 275e1683b8 Report the screen's own IP, and make the Wi-Fi name an honest optional
A customer read the device page's IP as their screen's address and reported it as wrong.
It was not wrong, it was a different thing: devices.ip_address is the PUBLIC address the
server sees the connection arrive from. Both are useful — you want the public one to
recognise a site, and the local one to actually reach the panel — so the page now shows
each, labelled.

The player already computed its own address for the connectivity report; it just never
reported it. Read straight off the interfaces, so Ethernet panels get it too, and it needs
no permission. Stored on device_telemetry beside wifi_ssid/wifi_rssi, where the
per-heartbeat network facts already live, rather than as another devices column.

The same customer saw "Unknown" for the Wi-Fi name and assumed it needed device-owner
access. It needs LOCATION: Android 8.1+ returns the literal "<unknown ssid>" to an app
without it. So "Unknown" was us reporting a permission gap as if the network had no name.

The player now distinguishes not-allowed-to-know from genuinely-no-Wi-Fi, and the page says
"Needs location permission" instead of a blank. The permission is declared but NEVER
requested at startup and nothing else uses it — a signage player demanding location to
display a network name is a bad trade. It is an opt-in row on the setup screen, using the
same Enable/Manage pattern, and refusing it changes that one field and nothing else.

Also caught by the test suite, and worth recording: the first version of this dropped the
comma in the device SELECT list ("t.uptime_seconds t.local_ip"), which 500'd the endpoint
and failed seven tests that never mention telemetry. Verified end to end afterwards —
public and local addresses both returned, distinct, from a real request.
2026-07-29 21:57:59 -05:00
ScreenTinker 3f0db335d2 chore(release): v1.9.25 2026-07-29 20:38:47 -05:00
ScreenTinker a25c6827a7 Show every plan on the admin tab, with who is on each
The admin plan table read /api/subscription/plans, which filters `active = 1` because
that endpoint feeds the public pricing page. So the one screen meant to show the
operator what plans exist could not show a hidden one — a comped or beta tier was
invisible to us as well as to customers, with no way to see it existed or who was on it.
Found immediately after creating exactly such a plan.

GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number
of accounts, organisations and screens on it. Visible plans sort first so the list still
reads like the pricing ladder, with hidden ones after and badged.

The public endpoint is deliberately untouched: hiding a plan has to keep working, and
the test pins BOTH directions because they pull against each other — the admin list must
include an inactive plan, and the public list must never leak one.

Counts are the point, not decoration: "how many people are on what plan" is the question
you actually ask of this screen, and it was answerable only by hand in SQLite.

Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and
organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so
this should be unreachable — but migrations here do rebuild tables with foreign keys off
(the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be
orphaned. Six lines for a state that would otherwise be silent.

Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup
falls back to English, and four Hindi strings among forty English ones would read worse
than consistent English.
2026-07-29 19:36:10 -05:00
ScreenTinker 3159f94107 Make unblock stick, and say so when a device is refused
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.

1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.

2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.

(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)

3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.

Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.

Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
2026-07-29 18:44:11 -05:00
ScreenTinker c115ad5e62 chore(release): v1.9.24
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-28 23:37:59 -05:00
ScreenTinker c779d62d63 Add an operator override for self-update on MDM-managed panels
A player stands down from self-updating when another device owner manages the panel,
on the assumption that the MDM distributes packages instead. That assumption does not
always hold: an operator may run an MDM for policy alone and still want ScreenTinker's
OTA to own the player. Until now there was no way to say so — the stand-down was a
client-side decision with no operator input.

OTA_ALLOW_MANAGED_DEVICES=1 makes the server advertise `allow_managed: true` in
/api/update/check, and players skip the stand-down. Default off: the safe behaviour
stays the default, and only an explicit opt-in changes it.

Absence is not consent. The client parses the field with a false default, so a newer
player against an older server that has never heard of it still stands down; and the
server always emits the key, so a player can tell "the operator said no" from "this
server has no opinion". Config parsing is strict for the same reason — only 1/true
enable it, and anything else, including a plausible typo like "ture" or "yes", lands
on the safe side rather than riding JavaScript truthiness.

This deliberately does NOT grant silent install. Off device-owner, and without
DELEGATION_PACKAGE_INSTALLATION delegated by the MDM, Android still raises a confirm
dialog somebody has to accept, so the override alone will not fix a fleet whose
installs are failing at that dialog — delegating the scope is the real fix there. The
README says so at the point of use, because reaching for this flag is the natural
mistake.

Only reachable because the stand-down now runs after the version check rather than
before it; it needs the server's answer in hand to consult.
2026-07-28 23:07:30 -05:00
ScreenTinker bcb1b5c7a3 chore(release): v1.9.23 2026-07-28 20:43:34 -05:00
ScreenTinker 0df7f58b26 Parse MAX_FILE_SIZE, and document what else caps an upload
Follow-up to #233, which made the upload ceiling configurable — the right call,
500MB is genuinely too low for video.

An environment variable is a string, so the value reached multer's
limits.fileSize as text where a number is expected. That survives some
comparisons through coercion and misbehaves in others, which is the worst kind
of bug to find later; the line directly above it already used parseInt for the
same reason. It is parsed properly now, and a suffix is accepted — someone
raising a limit for video is choosing "about 2GB", and 2147483648 is easy to
mistype by a factor of ten.

An unparseable value falls back to the default rather than becoming NaN or
zero. Either would reject every upload on the instance, from a typo in an env
file, with nothing on screen to explain it.

The documentation matters as much as the code here. MAX_FILE_SIZE is the LAST
limit in the chain: nginx caps the request body with client_max_body_size and
returns 413 before the app is reached — our own deployment sets 500M — and
Cloudflare caps uploads per plan at the edge. Raising the variable alone often
changes nothing, so the README now says so, with the nginx directive and a note
that an upload failing with nothing in the server log never reached the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:40:14 -05:00
a10kiloham 991c0da25a
Use environment variable for maxFileSize (#233)
Thanks — the hard-coded 500MB cap was genuinely too low for video, and making it configurable is the right call.

Merging as-is for the credit; a follow-up commit fixes two things this needs to actually work:

1. `process.env.MAX_FILE_SIZE` is a string, so the value reached multer as text rather than a number — the line directly above uses `parseInt()` for the same reason.
2. Raising it alone is not enough behind a reverse proxy. nginx caps request bodies at `client_max_body_size` (500M on our own deployment) and returns 413 before the app sees the upload, and Cloudflare's own cap applies too. That is now documented in the README alongside the variable.

The follow-up also accepts a suffix (`MAX_FILE_SIZE=2GB`) since typing the byte count is easy to get wrong.
2026-07-28 20:36:14 -05:00
ScreenTinker 3e3d0081fe Keep the smoke test out of npm test, and update the lockfile
Two mistakes in the previous commit, both of which broke CI.

The lockfile was not regenerated after adding puppeteer-core to
devDependencies, and `npm ci` requires the two to agree — so every job that
installs dependencies failed before running anything.

The smoke test was also placed in test/, which I described as keeping it out of
`npm test`. It does not: `node --test` globs that directory, so the runner
picked it up regardless of intent, tried to drive a browser as a unit test, and
failed. It now lives beside the server as smoke-ui.js, with a note saying why,
so the next person does not put it back.

Verified the way it should have been the first time: npm ci succeeds, native
modules still load, npm test is 807/807 with no browser involved, and
`npm run smoke` is 32/32 on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:34:34 -05:00
ScreenTinker 29ae184b14 Add an opt-in browser smoke test
A whole class of defect found today was invisible to the unit suite, to a
syntax check and to review, and appeared only in front of a browser: a context
menu whose only item read "schedule.ctx_new", pointer handlers stacking on every
calendar render so one drop fired five PUTs, and a week grid that scrolled
sideways on a phone. Nothing in the repo could have caught any of them.

This keeps the checks that earned their place and throws away the scratch
scripts around them. It boots a server, drives every view, and asserts each view
renders, none raises an uncaught error, no untranslated key reaches the screen,
the calendar binds its handlers once however many times it re-renders, and
nothing overflows horizontally at phone width.

Deliberately NOT part of `npm test`. It needs a real browser, which CI does not
have, so it is `npm run smoke` and exits 0 with an explanation when puppeteer or
Chrome is missing — a test that fails for want of tooling teaches people to
ignore failures. puppeteer-core rather than puppeteer, so installing it does not
pull down a private copy of Chrome; it drives whichever one is already there.

Verified both ways: 32/32 against current main, and it fails on the listener
stacking when that fix is reverted. The missing-key case is covered by the unit
guard instead, since a context menu only exists once it has been opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:17:59 -05:00
ScreenTinker 7747d7e051 Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket
Three loose ends from the interface review.

Inviting a colleague is a core action and had no entry in the navigation at
all. The only route was an unlabelled icon beside the workspace name, or typing
the URL. There is now a Members item, translated, which resolves to the active
workspace so the static link needs no id. The Teams entry it sits near stays
hidden, since that feature is still switched off.

A native title= is hover-only, so the icon-only buttons — rename a wall, remove
a device from one, manage members — explained themselves on a desktop and said
nothing on a touchscreen. Long-pressing one now shows its label. The text was
already there and already translated; it simply had no way to reach a finger.

The last one is the bug that took a real screen dark. A device row can vanish
while its socket is still heartbeating, and the telemetry insert then failed a
foreign key. That throw was fatal in a way that is hard to guess: the
safe-socket wrapper reads a throwing handler as a broken one and disconnects
the socket server-side, and socket.io deliberately does not retry that kind of
disconnect — so the player sat doing nothing until a person reloaded it. A
heartbeat for a device that no longer exists is an ordinary race, not a fault
worth ending a connection over; the write is skipped and the register path
answers unpaired, which is the reply that actually helps the client recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:00:35 -05:00
ScreenTinker 618af0811a Translate the labels that never went through t()
A title= is a tooltip the user reads and an aria-label is what a screen reader
says, but fourteen of them were hardcoded English. They were invisible to the
key checks added earlier precisely because they never call t() — so a French
user hovering the only route to workspace members read "Manage members", and a
German screen reader announced every modal's close button as "Close".

The user-visible ones matter most: the workspace switcher's Manage members and
Rename, the video wall's rename and remove, and the dashboard's select-for-wall.
All are translated into every active locale, along with the close buttons.

A test now rejects a capitalised literal in a title or aria-label, since that is
the shape this takes and nothing else catches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:52:52 -05:00
ScreenTinker 0a9a749475 Make help tips reachable, and explain the pages that had none
An audit of every view turned up two problems with the in-product help.

The tips only appeared on :hover. On a tablet or a phone there is no hover, so
the entire explanation layer was invisible to touch users — a large share of
the people administering signage — and unreachable from a keyboard. Tapping a
marker now opens it, Escape or a tap elsewhere closes it, and the marker is
focusable so Tab reaches it and a screen reader announces it. Bound once at the
document level and applied by observing the DOM, because views render from
about twenty call sites and modals appear later still; hooking each one would
have left the next new route silently unreachable again.

Four views had no tip at all. Playlists is the important one: a playlist is the
concept the reported confusion was actually about, and the page said nothing
about what one is or how it reaches a screen. Activity and Settings now have
one too. Help does not, because it is the help.

The schedule tip described a product that no longer exists — it said to click
Add Schedule, predating the drag, resize and right-click gestures. Rewritten.

All four are translated into every active locale rather than left to fall back
to English, since a tip falling back is a non-English user being handed an
English paragraph at the moment they are confused. hi.js stays deliberately
empty per the note in that file. Tests now check that every tip is translated
everywhere, and that a tip marker never names a string that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:37:49 -05:00
ScreenTinker 832a9c9bb2 Draw a schedule that runs past midnight
10pm to 4am is an ordinary signage schedule and the playback engine has always
understood it — schedule-eval treats an end before a start as a wrap. The
calendar did not. It computed four minus twenty-two, got negative eighteen
hours, and drew an eighteen-pixel sliver at 10pm with nothing at all after
midnight. The schedule played correctly while appearing broken.

An overnight window is now split into the pieces a week grid can draw: the part
before midnight on its own day, the part after it on the next, squared off
where they meet so they read as one window rather than two schedules. The
tooltip names the whole span, since neither half shows it alone. A Saturday
night spill is simply not drawn rather than wrapped round to Sunday, where it
would appear to have played six days early.

Dragging one is refused. A drag describes a window inside a single day, so
applying it to a wrap would clamp it into that day and silently destroy the
schedule — the same reason a recurring schedule's day cannot be dragged.

Verified in a browser against a real 22:00 to 04:00 schedule: 88px on Tuesday
night, 176px on Wednesday morning, alongside an ordinary daytime block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:28:04 -05:00
ScreenTinker a62396c2dd Attribute a widget play to the widget that played
A widget playlist item carries its id in widget_id and has no content_id at
all. The player sent only content_id, so a widget play arrived with nothing
identifiable and was written with both columns null — and play_end bound
content_id to BOTH columns, so that row could never match itself and was never
closed or given a duration.

Nothing looked broken: a row existed for every play. It just named neither what
had played nor which widget, and never ended. Reports read empty for any screen
showing a widget, which is most of the interesting ones. Seen on a live screen
playing a single widget: one open row, both columns null.

The player now sends widget_id alongside content_id, and a name falling back
through the fields a widget item actually has, so the event records what played
even when neither id resolves. The server prefers an explicit widget_id and
keeps the old content_id sniff as the fallback for players that predate this,
so an older client that puts a widget id in content_id still attributes
correctly.

Found by reading a real screen's proof-of-play rather than the code. The first
attempt at the fix broke the statement outright — the explanatory comment was
placed inside the SQL template literal, where a JS comment becomes SQL, and the
server logged `near "/": syntax error` on every play_end. Comments now sit
above db.prepare(), with a note saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:06:56 -05:00
ScreenTinker 268bd5e7fb Stop shipping untranslated keys as user-facing text
Driving the app in a real browser showed a context menu whose only item read
"schedule.ctx_new". t() returns the KEY when a string is missing — it never
returns undefined — so a missing key renders literally, and the common
`t('x') || 'A readable default'` guard is dead code: the key is truthy, the
default can never fire, and the pattern hides the problem instead of covering
it. Every occurrence of it in the app was doing exactly that.

Nineteen strings were affected, most of them predating this work: fifteen in
the self-hosted update panel and four in video walls, all of which have been
showing raw keys to users. The intended text was recovered from the dead
defaults, so the wording is the authors' own, and the defaults are removed
rather than left to imply a safety net that does not exist.

A test now walks the views for the keys they actually ask for and fails on any
that English does not define, and separately rejects the `|| default` pattern.
Neither problem is visible to a syntax check, a unit test, or review — only to
someone looking at the screen — so the guard is the only thing that keeps them
from coming back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 18:16:32 -05:00
ScreenTinker 68dd1b3e05 Tell people what to do next, from what the account actually contains
A user reported not knowing how to get content onto a screen. There was already
onboarding — a modal wizard — but it is gated on a localStorage flag: skip it
once and it never comes back, and it never knew whether you succeeded at
anything. Someone who closed it was left with no thread to pull, which is
exactly what was described.

A second tour would repeat that mistake. Tours are dismissed and forgotten, and
they describe the product rather than the account. This is a checklist on the
dashboard that reads real state, so it cannot claim you have done something you
have not, it is still there tomorrow, and it names the one thing to do next
rather than everything the product can do.

The steps are the shortest true path to a screen showing something: connect a
screen, add content, put it in a playlist, send it to the screen. Only the last
one cannot be satisfied by creating an object and walking away — a screen has to
actually be pointed at something — so an account full of playlists with nothing
playing is correctly reported as unfinished, which is the failure that was
reported. Steps stay in dependency order, so nobody is sent to a page they
cannot use yet.

It disappears on its own once the first screen is live and can be hidden before
then, so it never nags someone who already knows the product. Once hidden or
finished it costs no extra request at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 18:06:47 -05:00
ScreenTinker ce7d8642fa Make the calendar's gestures work on a touchscreen
The drag gestures did nothing on a phone. touch-action was set to none only
once the pointer had already travelled far enough to count as a drag, and by
then it is too late: a browser decides at touch-START whether a gesture scrolls
the page, so the page scrolled, the pointer stream was cancelled, and the block
never moved. The rule that works for a mouse cannot work for a finger.

Touch now arms by HOLDING. A press that stays put for a moment takes the
gesture over — at which point scrolling is suppressed and the block dims — while
a press that moves first is left alone as the scroll it plainly is. Everything
that is not a drag still scrolls exactly as a phone user expects. A mouse or pen
is unchanged and arms as soon as it has travelled.

Tapping empty space now creates a default one-hour slot at that time. On a
phone that is the only practical way to create, since drawing a range with a
finger is awkward, and on a desktop it is a shortcut worth having anyway.

The arming rule is a function rather than a pointerType check at each site, so
the touch and mouse paths cannot drift apart, and it is tested — including that
the hold is long enough to mean intent without feeling stuck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 17:56:48 -05:00
ScreenTinker d2d7911efb Make the calendar's blocks easy to grab and move
Direct manipulation existed but was awkward, and one part of it was outright
broken. A drag was recognised on ANY pointer movement, so the pixel or two of
travel in an ordinary click counted as a drag and suppressed click-to-edit —
the most common interaction on the calendar would have felt broken. A press now
has to travel a few pixels before it becomes a drag.

At 28px per hour a fifteen-minute block was seven pixels tall. Legible, but not
something a pointer can reliably hit, and its resize grip would have covered the
whole block. Rows are 44px, which makes the smallest block an 11px target while
still fitting a full day on a laptop screen; a test pins both halves of that
trade so neither can be tuned away silently. That height had been written as a
bare 28 in five places in the view that all had to agree with the module — it is
now one constant.

The rest is feedback. A block shows a grab cursor, dims while it is being moved
so it is clear what is travelling, and its grip is taller with a visible edge.
While dragging, the grid switches to a grabbing cursor and suppresses touch
scrolling, so the gesture works on a touchscreen instead of panning the page.
Pointer capture is released and the chrome reset on every exit path, including
a cancelled drag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 17:47:58 -05:00
ScreenTinker 98bde220ff Make the week calendar directly manipulable
The calendar rendered schedules but could not be used to change them. Creating
or moving anything meant opening a dialog and typing times, which is the wrong
instrument on a week grid: the grid already shows exactly where a thing goes, so
the grid should be where it is put. My previous change made the grid easier to
READ — all screens at once, a colour and a name per target — and left the
interaction untouched, which was only half of what was asked for.

Three gestures now share one pointer loop. Dragging empty space draws a slot and
opens the dialog prefilled with the time drawn, so the gesture supplies the
times and the dialog supplies only what it alone knows. Dragging a block moves
it. Dragging its bottom grip resizes the end. A live ghost shows the range as a
readable time while dragging, and nothing is committed until release, so an
accidental nudge costs nothing. Right-click acts on what is under the pointer:
new here, or edit, duplicate and delete on a block.

Dragging a repeating schedule sideways is refused. A one-off's day IS its date,
but a repeating one's day comes from its rule, so moving an instance across
columns would rewrite the recurrence for every other occurrence — a different
operation, and not one a mouse gesture should perform silently. Changing a
repeating schedule's TIME does still edit the whole series, since a series has
one time of day, so that is confirmed out loud rather than assumed.

The arithmetic is a separate module of pure functions, because it is the part
that fails quietly: a block that ends before it starts, a move near midnight
truncated instead of slid back, or a stamp built with toISOString() putting
anyone west of Greenwich on the previous day. Tests pin each of those. That last
one was already present in the create path and is fixed here too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 16:50:03 -05:00
ScreenTinker 433fbef191 Re-establish a player socket the server closed
socket.io does not retry every disconnect. On 'io server disconnect' it stands
down deliberately and waits to be told to reconnect. The player assumed the
opposite in two places: the disconnect handler stopped the watchdog because
"socket.io owns the reconnect once it KNOWS it's down", and verifyLivenessSoon
skipped a present-but-disconnected socket for the same stated reason.

So when the server closed a socket — a handler throwing, a deploy, an eviction
— nothing was left watching and the player stayed down until someone reloaded
the page. That is what it does on a wall: nothing, indefinitely, with no error
on screen. It happened to a live panel whose heartbeat hit a constraint error;
the server dropped the socket and the display sat dark until reloaded by hand.

A supervisor now backs up every disconnect the client did not itself initiate.
It re-establishes only a socket that is genuinely not connected, and only after
a grace longer than socket.io's maximum backoff, so the reconnection socket.io
does own is never raced. Our own teardown is excluded, since connect() closes
the previous socket before opening the next and supervising that would fight
the attempt already in flight. A resume now hands a stranded socket to the
supervisor rather than assuming someone else has it.

The decisions are pure functions alongside the existing watchdogShouldReconnect,
so they are testable without a browser, and a test asserts the grace still
exceeds the configured backoff ceiling if either is ever retuned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 16:38:19 -05:00
ScreenTinker 19d1e3e19f chore(release): v1.9.22 2026-07-28 14:58:22 -05:00
ScreenTinker 6268c1a4c0 Let a screen-only panel clear its identity from the URL
A display panel has no keyboard, no pointer and usually no way to clear site
data, but the URL it loads is configurable from whatever manages it. Loading
the player with ?reset=<token> now discards this install's identity so the
panel returns as a new device with a fresh pairing code — the recovery path
when a panel is holding an identity that belongs to a different screen, and the
ordinary path when redeploying a panel to another site.

It applies once per token, which is the whole design. A configured URL is
permanent; nobody goes back and removes the parameter. A reset that fired on
every load would drop the pairing on every reboot and present as a screen that
cannot hold its pairing at all — which reads as an intermittent server fault
rather than the URL doing exactly what it was told. The applied token is
remembered, so ?reset=1 left in place forever resets exactly once; any other
value resets again.

The server URL is deliberately kept, since clearing it would strand a panel
that cannot be typed into, and the cached playlist and layout are dropped so
the new device does not come up showing the previous screen's content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:58:21 -05:00
ScreenTinker 2bcc46bc26 Give each player install its own identity
The web player derived its fingerprint entirely from hardware traits: user
agent, screen geometry, colour depth, timezone, core count, platform and a
canvas raster. Every one of those describes a model rather than a unit, so two
identical panels produced the same value and the server, which matches on that
value globally, treated them as one device. Two UniFi Pro Displays at different
sites both produced web-m73u8w-5f; the second could not be brought online, and
the row ended up shared, each display evicting the other every thirty seconds.

The identity a player presents is now hardware plus a random per-install salt
kept in localStorage, so two identical panels differ from their first
connection. This is what the Tizen player has always done; the web player is
brought in line with it rather than given a new scheme.

The hardware value is still sent, but only as a hint, and only to move a caller
that has ALREADY authenticated with a device id and token onto its own row —
which is how an existing player carries its identity across this change. A
caller without credentials never resolves through it, however few rows it
appears to match: one row recorded does not mean one display exists, and that
distinction is the whole bug. Such a caller is provisioned a new device, which
costs one pairing code and cannot be wrong.

Older clients are unaffected. They send no hardware value, so they take the
exact-match path exactly as before, and both keep working: the APK's
fingerprint already includes ANDROID_ID and the Tizen player's is already a
stored random id, so neither ever shared an identity between units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:54:00 -05:00
ScreenTinker f09dee810c Record where a player crashed, not just what it said
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Three players died with "Cannot set properties of null (setting 'textContent')"
and it could not be traced. The message names no file, and every candidate line
in the current player was ruled out by inspection: the unguarded writes all
build their element with createElement, every getElementById target exists in
the markup, and the script runs after the markup. That points at an older
cached build still served by the service worker, which is exactly the case
where reading current source proves nothing.

The ErrorEvent already carried filename, lineno and colno. They were being
discarded. Keeping them makes the next occurrence name its own line.

Composed to fit the 200 characters the server stores, so the location is not
truncated away: message plus one location, basename only since the origin is
already known from the device. A promise rejection has no filename, so it falls
back to the first stack frame. A cross-origin script, which reports a bare
"Script error." with nothing else, says so rather than emitting :0:0 as if that
were an answer.

A resource load failure still is not a crash; a test guards that, since this
touched the handler that decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:17:43 -05:00
ScreenTinker b34d73dbb9 Report zero event-loop lag when a window recorded no samples
A sampling window that recorded nothing leaves the histogram empty, and an
empty IntervalHistogram reports its mean as NaN. Its percentiles return a floor
instead, which is why only the mean was affected and why this went unnoticed.

NaN then survives every arithmetic step in the sampler without complaint and
becomes visible only at the edge, where JSON.stringify renders it as null. So
/api/status served "mean_ms": null while nothing raised an error anywhere, and
any consumer of that gauge read null instead of a number.

Non-finite readings now report 0, which is the honest value: no samples means
no measured delay. Applied to every field so a later change to the histogram
source cannot reintroduce this one field at a time.

Found by CI rather than locally, because an idle window is far likelier on a
loaded runner with several test servers in flight. The failure was real; the
new tests establish the NaN premise and the null serialisation directly rather
than relying on that timing to reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:11:32 -05:00