Commit graph

675 commits

Author SHA1 Message Date
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 54f1f62762 Merge branch 'fix/group-drag-actually-moves' 2026-07-29 21:33:56 -05:00
ScreenTinker ead452d9b1 Dragging a screen onto a group now moves it instead of adding it
Reported by a customer with two screens and two groups: dragging a screen from one group
to the other showed a confirmation, changed what the screen was playing, but left the
displays page showing the old group — and a second attempt said it was already in group 2.

All three observations were correct. The drop handler borrowed the Manage modal's
"add it to X too?" confirm, then called addDeviceToGroup and nothing else, then reported
"Moved {name} to {group}". So it asked about adding, claimed to move, and added: the
screen ended up in BOTH groups. The page was not stale, it was accurate — and the retry
was right too, because by then it really was in group 2 as well as group 1.

The screen's content DID change because joining a group syncs the device's playlist to
the group's, which is why it looked half-applied rather than broken.

Drag is a move gesture, so it now removes the other memberships after adding the new one
— add first, so a failure leaves the screen in the group it already had rather than
ungrouped by a half-finished move. A removal that fails warns rather than reporting
success it did not achieve.

The Manage modal is deliberately left alone: its checkboxes are add/remove and its "too?"
wording is accurate there. Multi-group membership is a real feature; it just is not what
dragging means.

Not merely cosmetic: deviceSyncGroup() notes it picks "deterministically if it's somehow
in several", so a screen left in two sync-enabled groups gets an arbitrary one. A
half-completed move leaves synchronised playback ambiguous.

Strings added to the six locales that carry the dashboard set; hi.js has none of them and
falls back to English.
2026-07-29 21:33:56 -05:00
ScreenTinker 3f0db335d2 chore(release): v1.9.25 2026-07-29 20:38:47 -05:00
ScreenTinker 2906e559cb Advance the versionCode baseline past the published test builds
Three prereleases were cut for #234 and handed to the reporter, consuming versionCodes
89 through 93 via VERSION_CODE overrides that were never written back to this file. The
committed default was still 88, so bump-version.sh would have produced 89 for 1.9.25 —
an APK that installs over nothing anyone has been testing, since Android refuses a
lower-or-equal code, and silently so from the user's side.

Set to 93 so the next bump lands on 94, above every published build.

Lesson worth keeping: a VERSION_CODE override for a one-off build leaves this file lying
about where the release line actually is.
2026-07-29 20:38:46 -05:00
ScreenTinker bb016b8313 Merge branch 'feat/admin-plans-with-counts' 2026-07-29 19:36:10 -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 10f1dccdc8 Merge branch 'fix/unblock-clears-saved-block' 2026-07-29 18:44:11 -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 39c4ec8af8 Merge branch 'fix/setup-permissions-revocable' 2026-07-29 18:27:45 -05:00
ScreenTinker 8eff6d57d1 Let permissions be turned back off from the setup screen
Every row on the setup screen hid its button once the permission was granted
(visibility = GONE), which made each one a one-way door. None of these can be revoked
by the app — they all live in system Settings — so hiding the only route to that screen
removed the way back entirely. Asked on #234: "if I make the app as Home launcher but
later on want to remove it then how can I do it?"

The button now stays and relabels to "Manage", with the same destination. Two rows
needed more than a relabel, because their existing destination was a dead end once
granted:

  - Battery: ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS only ASKS to add an
    exemption and cannot remove one. An already-exempt user now goes to the system
    list (verified: Settings$HighPowerApplicationsActivity).
  - Notifications: requestPermissions() does nothing once the answer has been given.
    Now opens app notification settings, which toggles either way.

Also fixes the launcher row disagreeing with itself. The status read
resolveActivity(MATCH_DEFAULT_ONLY), which can name us for merely being a HOME
candidate, while the button asked RoleManager. So the row could say ON while the OEM
launcher was still home — and the button would then offer to BECOME home rather than
open the picker. That is the other half of the same report: "in the apk I have granted
the permission ... BUT in the settings of the tablet it still shows the tablet native
launcher as home." Status and action now ask the same authority.

Verified on an Android 12 tablet, both directions: not-home reads OFF/Set; after
becoming home it reads ON/Manage and Manage opens the Home-app picker (DefaultAppActivity)
— a way out, which is what was asked for.

NOTE: this screen's strings are hardcoded English in the layout and in code ("ON",
"OFF", "Enable", "Continue Anyway"), so "Manage" matches what is already there rather
than introducing one translated word among twenty untranslated ones. Localising the
screen is worth doing and is deliberately not mixed into this change.
2026-07-29 18:27:45 -05:00
ScreenTinker b09bed645d Merge branch 'fix/provisioning-callback-relaunch-loop' 2026-07-29 18:01:42 -05:00
ScreenTinker 83c9bc5aa6 Clear ProvisioningActivity's service callbacks (the white-flash relaunch loop)
Reported on #234 as a screen that flashes white "over and over", unkillable — "there
is nothing we can do on the tablet". It is a leaked listener.

ProvisioningActivity installs onRegistered/onUnpaired/onPaired on WebSocketService and
then finish()es. The service outlives it and nothing ever clears them: MainActivity
assigns neither of those three, so nothing overwrites them either. onPaired therefore
stays wired to a destroyed Activity for the life of the process — keeping it alive, and
still firing.

And it fires often. The server sends device:paired on EVERY register, not only the
first. So: register -> paired -> the stale callback starts MainActivity with
CLEAR_TASK -> new Activity binds and registers -> paired -> again. Measured on an
Android 12 tablet with a bare paired device and nothing assigned: 240 activity starts
in 180 seconds, about 1.3 a second, indefinitely.

Android 12 is where it becomes intolerable rather than merely wasteful: every launch
draws a splash screen there, so each iteration is a visible white flash. The same loop
on Android 9 has no splash and reads as an occasional glitch — which is why it was
originally dismissed as unreproducible after a clean reinstall. A clean reinstall
starts MainActivity directly and never runs ProvisioningActivity, so the callback is
never installed and the loop never begins. Pairing is what arms it.

onPaired is now one-shot — the hand-off to MainActivity is all it was ever for — and
all three are dropped in onDestroy too, which covers backing out before pairing
completes.

Same device, same pairing flow, 180s: 240 activity starts and 240 splash screens
before, 0 and 0 after, with registrations falling from 240 to 2.

⚠️ No other callback is ever nulled either (there are ~20). MainActivity's are
overwritten by the next MainActivity so they self-heal, but each one leaks the previous
Activity until then. Worth a sweep; this commit fixes only the three that never get
overwritten.
2026-07-29 18:01:34 -05:00
ScreenTinker ce626c7e8e Merge branch 'fix/playlist-refresh-not-once-per-item'
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-29 14:13:42 -05:00
ScreenTinker bc00bc1eb1 Stop re-registering the device once per playlist item
PlaylistController.next() asks for a playlist refresh on every item advance, and
requestPlaylistRefresh() emits a full device:register. The server's register handler
runs 7+ statements plus the identity/fingerprint path and rebuilds the playlist
payload, then pushes the whole playlist back down. So a panel showing a 10-second
image re-registered six times a minute, indefinitely, and each reply fed a fresh
playlist into a controller that had to diff it — which is what kept the #234 restart
loop supplied.

It was buying nothing. The heartbeat already refreshes every 4th beat (60s), so the
periodic pull this duplicated happens either way.

Throttled at the single chokepoint rather than by editing callers, because the callers
have genuinely different intents — network-came-back, service-connected, per-item, and
the heartbeat itself — and ranking them would be guesswork. A shared floor keeps every
caller's meaning: recovery paths still refresh, they just cannot stack. The window sits
just under the heartbeat's own 60s so the two interleave instead of the throttle
systematically eating the pull we are relying on.

Measured on the reproduction over 240s: 9 registrations for 9 item plays before, 3 for
the same 9 plays after, with playback unchanged. The saving scales with how short the
items are — a 10s item goes from six refreshes a minute to about one.

Does NOT change what a refresh does, only how often one may be asked for.
2026-07-29 14:13:42 -05:00
ScreenTinker 3a681abda0 Merge branch 'fix/resume-playlist-position-across-recreate' 2026-07-29 09:13:30 -05:00
ScreenTinker 66d9dc7fef Resume the playlist where it was after an Activity rebuild (#234)
Reported as "if there are 2 pictures or one picture and one video only one plays",
and the reporter had never once seen the second item.

PlaylistController is constructed with MainActivity, so every rebuild gives it a fresh,
empty instance. The playlist then arrives — from the disk cache or the socket, it does
not matter which — and the controller sees "0 -> N items", treats it as a first load,
and starts at the top. Anything the panel does that recreates the Activity therefore
sends playback back to item 1.

That would be survivable if it happened rarely. On the reproduction it happened at
every item boundary: the device re-registers, the app relaunches itself with
NEW_TASK|CLEAR_TOP, onCreate runs, and playback restarts. The second item was on
screen for 135ms each cycle, which is why it read as "only one plays" rather than as
a glitch. Prod play_logs agree: the second item logging 0-1s durations while the first
accumulated every real second of playtime, on two unrelated customer devices.

Position now lives in ServerConfig, outside the object that keeps being rebuilt, and
start() resumes from it when the save is recent. A cold start, a stale save, a
shrunken playlist, a missing save, or a clock that jumped backwards all fall back to
starting at the top, so genuine first-runs are untouched.

This does NOT address why the panel relaunches itself once per item — that is the
noisier half and wants its own change. It does mean a relaunch costs a restarted item
instead of a playlist that can never advance.

Reproduced first, on an Android 9 emulator with the reporter's exact shape (12MP
portrait JPEG + 40s MP4): image 135ms before, a full 10.05s after, with the video
holding its 40.1s, over four clean cycles.
2026-07-29 09:13:30 -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 3b593e2a1c Merge branch 'feat/ota-managed-override' 2026-07-28 23:34:39 -05:00
ScreenTinker f5ce88c93f Keep retrying an install for a working day, and flag a human straight away
Three attempts inside one hour, then a day of silence, was calibrated for the wrong
cost. The ~8.7MB re-download that throttle exists to prevent is already prevented by
the APK cache — downloadAndInstall reuses a previously verified file, so attempts
2..N pull no bytes. What actually blocks these installs is a confirm dialog waiting
for somebody to walk past, and giving up an hour in guarantees nobody has.

The cap is now 40, roughly a working day at the 30-minute cadence, before falling back
to the existing daily retry. Two things had to come with it, because raising the number
alone would have made things worse:

Telling the operator is now a SEPARATE threshold from giving up. It used to fire at
the cap, so a bare bump would have pushed "this panel needs attention" from about an
hour out to about twenty. It fires at ATTEMPTS_BEFORE_FLAGGING (3) instead, and
statusFor keys on the same threshold, so a device reports manual_update_required as
soon as a human is demonstrably needed and KEEPS reporting it while it retries.
Previously the status dropped back to 'pending' once the backoff window elapsed, so a
panel that needed hands looked healthy in between attempts.

PackageInstaller sessions are now abandoned before a new one is opened. Every attempt
stages a full copy of the APK via openWrite, and a session whose dialog is never
accepted holds onto it. At three that was a rounding error; at forty it would be
~350MB of staged installs on hardware without it to spare, and would eventually trip
the per-app session limit.

The warning text no longer promises a 24h backoff it is not about to take, and says
what would actually fix it — accept the prompt, or have the MDM delegate install
permission.

The three tests that broke encoded the old thresholds and were rewritten to the new
intent rather than retuned to pass.
2026-07-28 23:31:47 -05:00
ScreenTinker 56abfa3579 Make "force update" actually forceful, and make it say what happened
The dashboard button sent the same checkForUpdate() the 30-minute timer calls, so it
was subject to every guard the timer is subject to, and every one of those guards
returns silently. The toast fires on ack.delivered — which only means the command
reached the device's socket — so a panel that was capped, or standing down under an
MDM, looked exactly like one that had updated. "You get the toast popup, but nothing
happens" was an accurate description of working code.

A forced run is a different thing from a timer tick: a human aimed it at one device
and is watching that screen. So it now

  - hands the attempt budget back (OtaThrottle.onForcedCheck), un-parking a device
    sitting in backoff instead of making it wait out the window,
  - overrides the MDM stand-down, since a targeted human action is a stronger and
    better-aimed signal than the global OTA_ALLOW_MANAGED_DEVICES switch,
  - and REPORTS the outcome, including the boring ones. "Already on the latest
    version" is the single most valuable line here: silence was indistinguishable
    from failure, and that ambiguity is the whole bug.

It also distinguishes "install launched" from "installed". Off device-owner Android
raises a confirm dialog somebody has to accept, and the gap between those two states
is precisely where the button appears to do nothing — so the report names which one
happened and says the dialog is waiting.

The timer path is unchanged and stays quiet on purpose: reporting every capped tick
would move a Fire-OS-restart flood onto the WS channel, which is what #139 fixed.

Verified on a real panel end to end: dashboard socket emit -> ack {"delivered":true}
-> "Force update check triggered (operator)" -> "Force update: already on the latest
version (1.9.23)". OtaBackoffCadenceTest additionally pins the retry cadence that
prompted this (3 fast attempts, then one per 24h, full budget back on a new release)
so it stops being re-derived from the source each time it comes up.
2026-07-28 23:24:01 -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 ed22693a6e Merge branch 'fix/transition-overlay-handoff'
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 22:35:40 -05:00
ScreenTinker 0b6907704e Merge branch 'fix/resume-self-advance-on-follower-exit' 2026-07-28 22:35:40 -05:00
ScreenTinker 0bd9cde418 Merge branch 'fix/self-ota-stand-down-requires-device-owner' 2026-07-28 22:35:40 -05:00
ScreenTinker d913397559 Hand the frame back cleanly when a wipe ends
Reported as one or two frames of the OUTGOING photo after every transition, before
the incoming one appears. Three things conspired, all at the moment the wipe ends.

The overlay is a translucent SurfaceView with setZOrderOnTop(true) and a clear colour
of (0,0,0,0). onDrawFrame() cleared unconditionally, before testing whether there was
anything to draw. finish() left RENDERMODE_CONTINUOUSLY on and only POSTED the content
swap and the hide to the main thread, so the GL thread got at least one more frame in
first: it cleared to fully transparent while the overlay was still visible, showing
straight through to the ImageView — which still held the previous photo, because the
swap had not run yet. Not a black flash; a see-through one. The same clear ran on the
failed/hard-cut path.

So: clear only when a frame is actually going to be drawn over it, and stop the render
loop in finish() on the GL thread rather than waiting for the main thread to park the
overlay. What stays on screen is then the wipe's final frame, which is the destination
image, and it is correct to leave it there.

That still left the hand-off itself racing. Hiding a Z-ordered SurfaceView is a
SurfaceFlinger transaction that is not synchronised with the app drawing the newly
mounted bitmap, so the hide can land a vsync before the paint and uncover the old photo
anyway. The overlay now lingers briefly before parking. It costs nothing to look at —
both layers are showing the same picture — and it removes the race rather than
narrowing it.

Measured on the panel with 64x36 frame classification over screen recordings: the old
photo reappeared after 1 of 4 wipes before, 0 of 14 after the first two changes. That
sampling runs through a virtual display and cannot see every composited frame, so it
bounds the problem rather than proving absence — hence closing the last gap by
construction instead of by measurement.

The web player never had this: it calls mount() and then hides the canvas synchronously
in one task, so both land in the same paint.
2026-07-28 22:23:23 -05:00
ScreenTinker 8cf395f63b Re-arm self-advance when follower mode is turned off
While follower mode is on — a video wall follower, or a group-sync member —
playCurrentItem() deliberately never calls scheduleAdvance(): the wall/group tick
owns the index instead. Leaving that mode cleared the flag but re-armed nothing, so
the item already on screen had no timer behind it and the playlist stopped dead.
Unchecking "sync" on a group froze every member showing an image, until the app was
restarted. A 30-frame sample of a real panel returned exactly one unique frame.

Video hid the damage: onVideoComplete() -> next() still fires once repeatMode drops
back to OFF, so a video playlist recovers on its own and only images and widgets
strand. Both wall and group exit run through setWallFollower(), so the fix belongs
there rather than in either controller.

The entering edge was wrong in the same way, oppositely: a timer armed by the last
playCurrentItem() stayed live across the transition into follower mode and would fire
a next() that fights the tick for the index. It is now cancelled.

Resume is measured from when the item actually started, so leaving sync 8s into a 10s
image advances in ~2s rather than restarting the full slot; an already-elapsed slot
yields 0 and the existing MIN_ADVANCE_MS backstop keeps that off a busy loop.
FollowerExit is a pure seam so the arithmetic is testable without a Handler.

Verified on the panel that reproduced it: "follower mode off — resuming self-advance
in 9233ms", same pid, 40 frames / 7 unique / 9 advances where it previously froze.
2026-07-28 22:23:02 -05:00
ScreenTinker 5ba60ffa1a Stand down from self-OTA only for a real device owner, and say so when we do
The MDM auto-detect added in #166 asked "is any device admin active outside our
package". On a stock Fire TV stick the answer is yes: com.amazon.tv.parentalcontrols
is registered, holding wipe-data and nothing else. A retail stick with no enrolment
anywhere therefore declared itself MDM-managed and opted out of updates for good —
one sat 12 versions behind (1.9.11 against 1.9.23) while the server offered it every
release in between.

Device admin is not device owner. isDeviceOwnerApp/isProfileOwnerApp are public since
API 21 and accept any package name, so the owner really can be read directly; the
comment claiming otherwise was the root of the over-broad test. Profile owner is not
enough either — on that same stick parental controls owns user 0 — so the check is
now a foreign DEVICE owner, and delegated install scope short-circuits it since an
owner that delegated installs to us wants us installing.

Where doubt remains the asymmetry decides it: standing down wrongly is silent and
permanent, while attempting wrongly is capped at MAX_INSTALL_ATTEMPTS and surfaces
manual_update_required. Better to be the kind of wrong that reaches a dashboard.

That visibility was missing too. The stand-down ran before the version check, so a
managed panel never learned an update existed and kept reporting ota_status 'none' —
indistinguishable from up to date, which is why nothing flagged it. It now checks
first and parks genuinely-managed panels in manual_update_required, announced once
per target version rather than every polling cycle.

ManagedLogic is a pure seam alongside TierLogic; the admin shapes under test are the
ones dumped from the real device.
2026-07-28 21:40:18 -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 5c95070d3a Bind the calendar's pointer handlers once, not once per render
A QA pass over my own changes found a real defect. attachGridInteractions ran
on every calendar render, but #calendar is the same element throughout — only
its children are replaced — so each render stacked another full set of pointer
handlers on it. Five weeks of navigation left five, which meant five ghost
blocks during a drag, five context menus on a right-click, and five PUT
requests on a single drop. Verified by counting listeners through the debugger:
five sets after five renders, one after this change.

Also guards the drag-to-create path. It reuses the Add Schedule button's own
handler so the dialog resets exactly as it does for a normal create, but it
called .onclick() unguarded — and a drag is a user gesture that must never
throw. A missing button now quietly does nothing instead of raising an uncaught
error in the middle of an interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:08:31 -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 a635120769 Say Teams is switched off instead of showing an empty list
Teams is disabled server-side while it is redesigned: every endpoint answers
503 with an explanation. The view did not notice. The API helper resolves the
response body whatever the status, so the 503's object arrived where an array
was expected, `!teams.length` was true, and the page rendered "No teams yet —
Create a team to share devices with other users" beside a New Team button that
could only ever fail. An inviting empty state over a feature that is not there
is worse than an error: it invites someone into a dead end.

It now shows the server's own explanation, which stays accurate when the
feature returns, and removes the button that leads nowhere.

Also enlarges the help tip's hit area. The marker is 18px, which is fine to
look at and about half the touch guideline — and since tapping a tip is now how
touch users read it at all, that mattered. A transparent inset overlay makes
the target comfortable without inflating the marker in a heading; a tap 9px
outside the visible circle registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:46:26 -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 653108624d Show one day at a time when the week will not fit
The week grid was a fixed 800px of seven columns. On a phone that is a
horizontal scroll through ~50px columns — too narrow to read a name or aim a
finger at, and the sideways scrolling fights the vertical drag gesture the
calendar depends on.

Below 700px it now renders a single day, with a strip of the seven dates above
it to move between them. The hour column narrows to match, and nothing scrolls
horizontally in either orientation.

Rotation crosses that boundary in both directions — a phone is about 390px
upright and about 844px on its side — so the layout is rebuilt on resize and on
orientationchange. Both are debounced: rotation fires a burst of resize events,
and on iOS the reported dimensions are briefly the pre-rotation ones, so
settling first avoids rebuilding against a size that is about to change again.
Only a crossing rebuilds; resizing within one layout leaves the view alone. The
opening scroll is re-aimed after a crossing, since it was measured against a
grid that no longer exists.

Verified by driving a real browser through portrait, landscape and back:
one column then seven then one, no horizontal overflow at any point, and the
day strip moves between days.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 18:52:39 -05:00
ScreenTinker d23442a1a1 Open the calendar on the working day and say what it is for
Two things a browser run made obvious that reading the code did not.

The week view opened at midnight. A new user landed on four hours of empty
night with every hour anything is actually scheduled in below the fold, which
reads as an empty product rather than an empty morning. It now opens on the
earliest scheduled hour, or the start of a working day when nothing is
scheduled yet, and only on the first render so it never yanks the view back
while someone is scrolling.

The grid is also its own scroll container now, with the day header pinned. A
full day at the new row height is a thousand pixels; without this the controls
scroll away and you lose track of which column you are in.

An empty calendar said nothing at all. It now carries a line explaining that
dragging across a time creates a schedule and right-click has more — placed
outside the grid so it cannot intercept the gesture it describes.

Getting there took two wrong attempts, both caught by looking: the hint was
first appended after the grid, which put it a thousand pixels below the fold,
and the scroll used offsetTop while the container was not a positioned
ancestor, so it measured from the page body and overshot by hours. The scroll
is plain grid arithmetic now, and the container is positioned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 18:39:05 -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