mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
102 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aa77332c0d |
Let the playlist preview skip, so reviewing item 8 does not cost seven durations
The preview shipped without the skip control #104 asked for, so checking a late item meant watching every item before it in real time — the thing operators do most when ordering a playlist with a client on the phone. The preview is already the real player in device-free mode (an iframe of /player?preview=1), so this drives that instance rather than growing a second playback implementation: the dashboard posts next/prev to the one contentWindow, the player steps its own currentIndex and re-renders through the same path a natural advance uses, and posts back index/total so the modal can say "3 of 7". Nothing here can reach a live screen. A real display is driven over its server socket and holds no window handle this page could address; the message listener is installed only by the preview boot path, previewNavigate refuses outside PREVIEW_MODE, and both ends pin the origin. Stepping is schedule-aware in the direction of travel — falling forward past a dayparted item would make "previous" walk forwards — and a multi-zone playlist reports itself as such, because all zones play at once and a counter there would be a lie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
0082191f9b |
Show only the controls a display can actually honour
Every device control was offered to every display. A browser tab was shown "Reboot device", a Tizen TV was shown screen power, a player with no framebuffer read was shown a live view that stayed black. They all looked like working buttons and did nothing — the "reports success and changes nothing" shape that keeps costing people days. Players now declare what they can do at registration, because only the player knows at runtime: an Android panel gains real screenshots when accessibility is switched on and loses Tier-2 when device owner is revoked. The dashboard hides what is not supported rather than disabling it, and the Info tab lists the capability set so a missing control is explainable. The declaration is three-state and the middle state is load bearing: NULL means "has never told us anything" and falls back to a per-platform baseline, because several hundred displays in the field will not update before this deploys and blanking their controls would be a far worse bug. An empty array means "I genuinely can do nothing" and is honoured. Hiding a button is not enforcement, so unsupported commands are also refused server-side — the socket is reachable directly and a stale tab still renders the old controls. Group sends report skipped devices separately from sent ones; counting an unreachable member as "sent" is how an operator walks away believing the whole group rebooted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
d205a49dfa |
The settings PIN can be rotated and set from the dashboard
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.
POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.
Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.
Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.
Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.
1084 pass; Android compiles.
Asked for by chris@chris-pc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
803f4ec26d |
Portrait templates, a canvas that matches the layout, and a playlist mockup
Three related pieces. Zones were already stored as percentages and layouts already carried their own width/height, so this is mostly design work rather than plumbing. SIX PORTRAIT TEMPLATES at 1080x1920. Deliberately not the landscape set turned sideways: "Three Column" at 33% each becomes three tall slivers, and a 15% ticker that reads well across 1080px is a 288px band on a 1920px-tall panel, so the portrait ticker is 12% and the PiP window is wider than tall (a 30x30 box is square on 16:9 and 324x576 in portrait). Seeded in schema.sql for fresh installs AND as a migration, because schema.sql never runs on an existing database — and upgraded instances are exactly the ones with portrait panels already deployed. THE EDITOR CANVAS followed a hardcoded padding-top:56.25% — the 16:9 ratio trick. Authoring a portrait layout meant dragging zones on a landscape canvas: the percentages landed correctly on the panel and looked wrong everywhere you designed them. It now derives from the layout's own height/width, clamped so a pathological row cannot produce an unusable editor. THE PLAYLIST PAGE now draws where content actually lands. A playlist has no intrinsic layout, so the server reuses #104's derivation from the items' own zone bindings and returns it. Previously an item could be tagged "Bottom Ticker" with nothing to say the ticker is a thin strip along the bottom — people assigned by zone name and found out by looking at a screen. Empty zones are dimmed, because an empty zone shows its background colour on a real panel and that is worth seeing before publishing rather than after. Verified against a copy of prod: 6 templates and 12 zones created, the 7 landscape templates untouched, no errors at boot, and a second boot changes nothing. Each stacked template's zone heights sum to exactly 100%. 1074 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
16b3dd949c | Merge: BrightSign real telemetry and hardware identity | ||
|
|
5a7277523a |
Wire BrightSign native sync end to end, chosen per group
st-sync.js wrapped SyncManager but nothing drove it. The player now does: the
leader opens a new sync session on each advance, and every member — the leader
included — binds the video with attachVideo() on a NEW id only.
The leader binds from its own broadcast rather than at announce() time on
purpose. Starting when it announces would put it ahead of its followers by the
width of the network, which is the one desync nobody would think to look for
because the leader always looks correct.
Item selection stays clock-derived under both backends. Native sync replaces
only the seek/nudge drift correction, because setSyncParams has the element hold
its own alignment and correcting it ourselves would fight the platform — every
frame we moved is one it then has to undo. Keeping selection on the shared clock
is also what keeps images and widgets, which have no setSyncParams, advancing
with the videos instead of drifting off alone.
LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding
a column. It already resolves pinned-if-online, else first online member on the
shared playlist, else first by id — deterministic, stable, and already what the
group-sync payload reports. A second mechanism could only disagree with it.
Added on top: a group whose elected leader is OFFLINE falls back to our
protocol. Ours is leaderless and carries on; native sync has exactly one
broadcaster, so those members would sit waiting for an announcement that never
comes, with the dashboard showing a healthy group throughout.
device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's
REQUEST; the answer comes from the existing pure resolveSyncBackend() so the
players, the dashboard and the stored setting cannot disagree. The resolved
backend, reason and downgraded flag ride in the group_sync payload and in the
group API, and the dashboard shows the refusal reason instead of a setting that
quietly isn't in force. An unrecognised value is rejected rather than stored,
because the resolver reads anything unknown as 'auto' — a typo would otherwise
return 200 and run a different protocol than the UI displayed.
FIXED WHILE HERE: the player re-entered group sync only when the group ID
changed, with a comment noting the clock protocol has no leader role. Native
sync has one, and neither a protocol switch nor leadership moving alters the
group id — so a player promoted to leader kept behaving as a follower, nobody
announced, and the group sat unsynchronised. The re-enter key now includes the
backend and the leader flag.
971 pass (+17).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
46b2227dfd |
BrightSign: real telemetry and hardware identity, not a block of nulls
The web player sent battery/storage/RAM/CPU as nulls and 'Web Player' in the
wifi_ssid column, and the bs_model / bs_os_version / bs_serial / bs_screen fields
the player already reported at registration were consumed by nothing. A browser
tab genuinely has none of that. A BrightSign has some of it, and was reporting
none.
Telemetry comes from a CACHE the heartbeat reads synchronously. The beat builds
its payload every 15s without awaiting, but the one real sensor here —
deviceInfo.getTemperature() — returns a promise; awaiting inside the beat would
either block it or serialise a pending Promise into the payload, which is exactly
how device_id once became "[object Promise]". The cache starts EMPTY rather than
null-filled and is spread last, so off-platform nothing changes and a null here
can never clobber a value another player family legitimately supplied.
wifi_ssid was actively false on a PoE Ethernet appliance — an operator reading
that column was told an SSID that does not exist. It is null there now, and the
device view shows a real hardware block instead. Android's WiFi display is
untouched.
Hardware identity is a SEPARATE writer from applyDeviceInfo, deliberately. That
function is a blind full-row overwrite, and an empty device_info once nulled
seventeen columns every five minutes because {} is truthy. These fields arrive
only on a full register, so the same shape would wipe them on every lightweight
refresh in between; COALESCE makes "no news" mean "unchanged".
The OS build gets its own column rather than reusing android_version, which is
load-bearing as a TYPE discriminator: device-detail chooses between the Android
and browser layouts with android_version.startsWith('Web/'), so writing
"BrightSign OS 9.0.189" there would have rendered a BrightSign with battery and
WiFi cards — and applyDeviceInfo would have clobbered it on the next refresh.
Storage is labelled "Player Storage", not "Storage": on this family the number is
the widget's cache quota, not the device filesystem, and it lands in the same
column as Android's real disk figures.
Schema: device_telemetry.temperature_c REAL; devices.hardware_model,
hardware_serial, hardware_os_version, output_index. All nullable, all idempotent
in the existing migration array.
973 pass (+19). The temperature tests drive a real socket into a real server,
because changing the arity of the telemetry INSERT would break every player's
heartbeat, not just BrightSign's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
ad18914736 |
Label BrightSign players as BrightSign, not "Web Player"
A BrightSign runs the same web player, so client_type is 'player' and the device
detail view fell through to a hardcoded "Web Player" — indistinguishable from a
browser tab on someone's desk, for a dedicated signage appliance.
Keyed on the platform the player now reports ('brightsign', from the
?platform=brightsign the host puts on the URL), with a user-agent fallback for
panels paired before that existed — those registered as "Chrome 120" with a
BrightSign user agent.
Only en carries the new string; other locales fall back to en, which reads
correctly since the label is a brand name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
9bcdaacd2c |
Show every screen's schedule on one calendar
The week view could only answer "what plays on THIS screen". With one screen at a time an empty grid is ambiguous — nothing scheduled, or the schedule points at a different screen? That ambiguity is what a user actually hit. Adds an "All screens" scope alongside the per-screen one. Every block now names its target, with a stable per-target colour and a legend, so a full grid stays readable. The scope for all=1 comes from the request's resolved tenancy and is filtered on nothing else, so the tenant boundary rests entirely on that resolution. Tests pin both halves: an ordinary tenant gains nothing by naming another workspace in the query string, and the platform-admin act-as path still resolves the workspace it asks for — the two are easy to mistake for each other, so they are asserted separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
0030acc526 |
Store a schedule in the timezone its screen runs in
Creation and playback disagreed about which clock a schedule's hours are on. The player evaluated blocks in the device's zone — an operator override, else whatever the player's OS reported. Creation defaulted to a bare 'UTC', because the dialog never asked for a zone and the server filled the silence with one. So hours typed as "09:00 to 17:00" were stored as UTC and evaluated somewhere else. For anyone outside UTC the schedule was correct and appeared to do nothing, opening hours later than intended, with nothing on screen to explain why. A user in Asia/Tokyo hit exactly this and reported it as "I added something and it didn't appear". Both sides now resolve through lib/device-timezone, so they cannot drift: an explicit device override wins, then the OS-reported zone, then null. A legacy 'UTC' override counts as unset, since that was the old default rather than a deliberate choice and a genuine UTC deployment is indistinguishable from an unconfigured one. A new schedule inherits its target's zone — the device's, or for a group its leader's, falling back to the oldest member that reports one. A zone named explicitly by the caller still wins; this only fills the silence. A target that has never reported one still lands on UTC, which is the previous behaviour made explicit rather than assumed. The dialog now states which clock the hours are on, and says so differently when that clock is not the operator's own. Stating it is the other half of the fix: the server can pick the right zone, but the user still has to be able to see it. Tests pin both directions and, most importantly, that creation and playback resolve identically from the same device row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
93019fdde4
|
Update Italian translation (#232) | ||
|
|
b7d55595af |
feat(auth): self-service password reset
Until now the only ways back into an account were an admin setting your password for you
or shell access to run scripts/reset-admin.js. A self-hosted operator who forgot their
password had no path at all, and the admin-reset route explicitly refuses to reset a
platform admin's password — so a single-admin instance was unrecoverable without a shell.
The per-account login lockout added recently makes that sharper: a user who forgets their
password will hit the lockout and see the same generic error, with no way out.
Two unauthenticated endpoints (they must be — the user cannot log in):
POST /api/auth/forgot-password { email } -> always the same 200
POST /api/auth/reset-password { token, password } -> 200 / 400
The properties that matter, each covered by a test:
- NO ENUMERATION. The request endpoint answers identically — same status, same body —
for a real address, an unknown one, an SSO identity with no local password, and a
malformed string. The frontend shows the same confirmation even on a network error,
so the client cannot leak what the server refused to.
- NO MFA BYPASS. Completing a reset does NOT issue a session; the user signs in
afterwards, so a TOTP-enabled account still clears its second factor. Returning a token
here would turn "read one email" into a full session without the second factor.
- SINGLE USE, SHORT LIVED. 32 random bytes, stored only as a SHA-256 hash (same
discipline as email verification, recovery codes and API tokens), 1h TTL, and the
redeeming UPDATE is conditioned on the hash still being present so concurrent
redemptions cannot both win.
- LOCAL ACCOUNTS ONLY. SSO identities have no local password; no token is minted.
- IT ACTUALLY UNBLOCKS YOU. A completed reset clears the per-account login lockout and
must_change_password, otherwise someone who locked themselves out would reset and still
be locked out.
Rate limited: 5/min on the request (it sends mail to a caller-supplied address), 10/min
on the redeem. If no email transport is configured the response is unchanged — no oracle —
but the server logs loudly, because the user will otherwise wait for mail that cannot
arrive and the generic response cannot tell them.
Frontend: a "Forgot your password?" link on the sign-in card, a request card, and a
new-password card. app.js had to learn #/reset-password explicitly — the auth guard
rewrites any unauthenticated hash to #/login, which would have discarded the one-time
token in the emailed link and made it silently do nothing.
Migration adds users.password_reset_hash / password_reset_expires: additive, nullable,
idempotent; a code-only rollback leaves two dead columns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2b137bc40b
|
fix(widgets): honest webpage-widget note — blocked sites don't work on device (#230)
The webpage-widget preview note claimed: "the site blocks embedding in a browser — it will still display on the device screen." The second half is false. The widget renders the URL in an <iframe> (renderWebpage), and the device player loads that page in a Chromium WebView, so a site sending X-Frame-Options / CSP frame-ancestors (Amazon, Google, most large sites/banks) is refused on the device exactly as in the browser preview. The note set the wrong expectation — a customer (and we) chased CORS and "should work on device" when the live device screen was blank too. Reword to tell the truth in all 6 languages (en/es/fr/de/it/pt), both the frontend i18n key (widget.webpage_blocked_note) and the player's preview_webpage_blocked string: if the preview is blank the site blocks embedding and won't display on the device either — try a page that allows it. Copy-only; no behaviour change. This is not an Amazon-side fix (embedding refusal is the site's choice) — just accurate messaging. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8529be5a30
|
feat(content): subtitle/caption support as a content property (#223)
Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).
- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
filter is video/image-only); stores the file in the content dir, records
subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
appended to the <video>, forced mode='showing' on load (same-origin, so
CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
video gets a .vtt file picker + language + a remove-subtitle option. en/es.
Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.
Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.
Closes #216
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8b661a7347
|
feat(content): batch operations — multi-select, batch delete, batch move (#224)
The content library had no batch operations — every item was managed one at a time. Add multi-select with batch delete and batch move. Backend (content.js): - POST /content/batch/delete — array of ids, atomic: validates + authorizes EVERY id first (malformed/missing/forbidden rejects the whole batch), then deletes in one transaction. Reuses the single-delete teardown. - POST /content/batch/move — array of ids + target folder_id, same atomic validate-all-first; target folder must share each item's workspace. Folder is organizational (not in the snapshot), so no device push. - Refactor: extract purgeContentRow() (file removal + snapshot scrub + row delete + affected-device collection) and pushContentUpdates(); DELETE /:id now uses them, so single + batch share one scrub path (no duplication). Add a boolean contentWritable() mirroring checkContentWrite's authorization. - 500-item cap per batch; UUID validation guards the snapshot-scrub LIKE. Frontend (content-library): - Per-card selection checkbox, select-all/none (visible), shift-click range. - Selection persists across folders/pages (issue-aligned cross-page selection); cleared after a successful batch op. - Batch toolbar (shown when >0 selected): count, move-to-folder picker, delete with click-again confirm. Selected cards get an outline. - api.batchDeleteContent / batchMoveContent; en/es i18n. Not included: batch "set expiry" (listed in the issue's toolbar sketch but only delete/move had endpoint specs) — deferred; PUT already does per-item expiry. Test: content-batch-ops.test.js — batch delete removes rows+files+scrubs snapshots; atomic rejection leaves valid rows intact; malformed id -> 400; batch move reassigns folder; cross-workspace folder refused; empty batch -> 400. Suite 553/553. Closes #213 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5c6d508032
|
feat(content): multi-file upload (#222)
Uploading N files fired N sequential XHRs (one POST per file). Select-many now goes up in a single request. - Server POST /api/content: upload.array-style `files` field (up to 20) via upload.fields, looping ingestUploadedFile per file. Keeps the legacy single `file` field so older clients / API callers are unaffected. Response shape is backward-compatible: a single file returns the content object (what every existing caller reads), a batch returns the array. - api.uploadContent: accepts a File, FileList, or array; appends all under `files`; aggregate upload progress; resolves to object (single) or array (batch). - content-library handleFiles: one batched request with aggregate progress and a "N files uploaded" toast instead of a per-file loop. - en/es i18n for the count-based progress/toast strings. checkStorageLimit is left as-is — it's a coarse pre-gate (blocks only when already at/over the limit), same as before; per-file aggregate sizing was a listed "consideration", not required, and is out of scope here. Test: content-multi-upload.test.js drives the real router+multer over HTTP — 3-file batch creates 3 rows and returns an array, legacy single `file` returns an object, single `files` returns an object, empty -> 400. Suite 545/545. Closes #212 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
792b105035
|
feat(content): server-side search, type filter, and sort (#221)
Content discovery was client-side only, scoped to the items already rendered
on the current page — searching "logo" on page 1 couldn't find logos on page
2 or in another folder.
Server (GET /api/content):
- ?q= text search on filename (LIKE, workspace-wide — a search ignores the
open folder so nothing is missed). LIKE metacharacters are escaped so a
filename with % or _ matches literally.
- ?type=video|image|youtube|web — youtube (video/youtube) and web (other
remote_url) are split from plain uploaded video/image so the four UI buckets
map cleanly.
- ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user
input into ORDER BY); default keeps the legacy newest-first ordering.
Frontend (content-library):
- Type filter + sort dropdowns; search debounced (300ms) and now hits the
server instead of filtering the DOM.
- Result count shown while a search/type filter is active.
- en/es i18n.
api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while
searching to match the server's workspace-wide behaviour.
Test: content-search-filter-sort.test.js mounts the real router and covers
substring match, LIKE-escape (literal %), the type buckets, name/size sort,
the ORDER BY injection guard, and combined filters. Suite 541/541.
Closes #214
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
ad03a5ec0a
|
feat(content): unstable-connection mode — cap YouTube at 720p for weak WiFi (#220)
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.
- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
reaches the player (that query enumerates columns, so it had to be added
explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
onReady when the flag is set. Both are hints YouTube may still override, but
together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.
Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).
Closes #217
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
e7483dfc24
|
feat(ui): show server URL in Add Display modal + GitHub Releases link on /download/apk (#210)
- Add Device modal now shows the server URL and full Smart TV player URL - Smart TV note changed from bare /player to full URL (dynamic via JS) - /download/apk error page now includes a download link to GitHub Releases - i18n keys added in en + es, old smart_tv_note removed |
||
|
|
b938fce368 |
feat(auth,tizen): TOTP 2FA UI, email verification on signup, Tizen SSSP install
Three features from this session, full server suite green (535/535). TOTP 2FA (#100) — backend shipped without a UI; add it: - Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify. - Settings > Account: enable (QR + confirm -> recovery codes once), regenerate, disable; SSO accounts see "managed by your identity provider". - /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri folds the request Host into the issuer so multi-instance accounts are distinguishable in the authenticator app. Email verification on signup — hosted HARD-block / self-host SOFT-nudge: - email_verified column; existing users asked on first login (SSO + platform admins grandfathered); single-use 24h tokens (SHA-256 hashed). - Gate engages only when email is configured (never locks out a no-mail instance). GET /verify-email + POST /resend-verification (generic, no account enumeration). - Client: "confirm your email" flow + resend, verified/error toasts, self-host banner; onAuthSuccess refuses a tokenless response (defensive). Tizen SSSP URL-Launcher install — Fusion-style one-URL native install: - Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served .wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the signed .wgt (/data mount wins, mirroring the APK). - build-wgt.sh also emits a static sssp_config.xml for CDN hosting. - Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
96b71a0d56
|
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated. |
||
|
|
178af029a4
|
Directory board: JSON/CSV import + logo-replaces-title + fix images on player (#195)
* feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text)
Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON
(the { company, tenantsByFloor, advertisements, backgroundImages } shape plus
categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon
table (with or without a header — vacant/yes/1 => available, quoted fields), or a
sectioned "room name" text list, and it auto-fills title, footer, floors->categories,
rooms/names/details/availability, and background-image URLs. "Replace / append" toggle.
Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …);
warns on things it can't use (bare-filename background images, headerless columns).
parseDirectoryImport is pure and was unit-tested in node across every format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(widgets): directory board — logo replaces title, and images load on the player
Two on-screen bugs on the directory board:
1. A logo did not remove the title text — both rendered, stacking the wordmark over
the name. renderDirectoryBoard (and the directory-search header) now gate the title
h1 behind !logoSrc, so a logo replaces the title. New render test guards it.
2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED,
0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin)
iframe, so /api/content image requests are cross-origin, and the helmet default
Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+
ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content
static route. Content already serves publicly, so no new exposure. Verified in a real
sandboxed iframe: same-origin blocks, cross-origin loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
a15086540f
|
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
* feat(widgets): add directory-search widget
An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.
Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
board, inlines its categories as one \u003c-guarded JSON blob, renders all
text via textContent (XSS-safe), live case-insensitive filter over
identifier/name/subtitle (debounced), grouped results, available styling,
optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.
Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(widgets): live-sync for directory-search (poll source board, no reload)
Reflect directory-board edits on an open directory-search page without a reload.
- New public GET /api/widgets/:id/data.json returns { categories } for a
directory-board (404 for missing/wrong-type so the page keeps last-good data
on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
widget iframe can read it; exposes only data already public via /render.
Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
data.json every 30s via a relative URL (works behind a proxy/base path and
from a null-origin iframe). Only rebuilds + rerenders when the data actually
changed, so a mid-search view isn't disturbed; skips while document.hidden;
keeps last-good data on any fetch error. Flatten logic factored into
buildFlat() and reused by the poll.
Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(android): let player WebViews take touch focus for interactive widgets
directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.
Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
87a1e02ff3
|
feat(dashboard): version loading indicator + immediate first poll (#181)
* feat(dashboard): show version loading indicator and fire poll immediately - Show "Verificando..." while /api/version resolves on first load - Fire first version poll immediately instead of waiting 15s - Fallback to "-" when version is unavailable * i18n: localize the version-check loading label The sidebar version indicator hard-coded the Spanish string 'Verificando...', shipping it to every user regardless of locale. Route it through i18n instead: new 'common.checking' key (en: 'Checking...', es: 'Verificando...'); all other locales fall back to the English canonical, matching the rest of the UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: ScreenTinker <hello@screentinker.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9c70fcc790
|
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause
Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.
Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.
Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
(router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.
Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.
Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.
CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).
Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)
Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
- link_lost=true -> Wi‑Fi/Ethernet link lost (device's own link)
- link up, internet_ok=true -> server_down: internet reachable, OUR server was unreachable
- link up, internet_ok=false -> no_internet: router/ISP down
- link up, no probe result -> generic router/upstream (unchanged fallback)
- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.
Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)
When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
2f3dd80881
|
feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171)
Agency-portal uploads previously all landed at the workspace library root, unsorted. Instead of the issue's whole-workspace folder dropdown (which would leak every folder name to an external party), bind ONE folder per agency token — admin-controlled and agency-invisible — and scope the portal picker strictly to that folder's own subtree (Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before. Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the token key IS that workspace's private link and the bound folder lives in that workspace. An admin with N workspaces mints one token per workspace (each with its own auto-folder). No workspace-switcher in the portal — the token is the tenant boundary. Backend: - api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls back to root). - lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents). - routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound folder and 403s any folder_id outside the subtree. - routes/tokens.js: create auto-creates "Agency — <name>" (or binds a picked folder, validated same-workspace, respecting the 100-folder cap) inside the token tx; new PUT /:id/upload-folder to rebind; listing surfaces the bound folder name. - middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest writes folder_id. Frontend: - Agency portal: folder <select> shown only when a real subfolder choice exists (identifies the "Main folder" root client-side without learning the token's folder id). - Settings: folder pick at token creation, bound-folder display, rebind modal. - i18n: 7 new apitoken.* keys across all 5 locales. Tests (429/429): - test/agency-folder.test.js: 5 folderSubtree confinement bites (subtree in, siblings out, workspace guard, null -> root). - test/agency.test.js (+1 e2e): auto-create, default-to-bound, in-subtree pick lands there, sibling -> 403, admin-pick, unknown-pick -> 400, rebind-to-root. Closes #158. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ef91f644a7
|
feat(system-control): Tier 0/1 controls with no device-owner dependency (#160) (#169)
* feat(system-control): Tier 0/1 controls with no device-owner dependency [#160] Track A of the system-control split (Track B = device owner, shipped in #168). Ships the capabilities that need NO device owner, with graceful per-tier degradation. Capability reporting (keystone): - DeviceInfo now reports can_write_settings / accessibility_enabled / overlay_granted alongside the existing tier/device_owner flags; server persists them (3 additive device columns, older APKs default to 0); dashboard gates controls + shows what's grantable. Android SystemControl (new, all best-effort / no-op when unsupported): - Tier 0 (no permission): media volume (AudioManager STREAM_MUSIC), per-window brightness (WindowManager.LayoutParams.screenBrightness — dims our window only). - Tier 1 (WRITE_SETTINGS): system-wide brightness + screen-off timeout (Settings.System). - Commands set_volume / set_brightness / set_system_brightness / set_screen_timeout wired in MainActivity.onCommand; ALLOWED_COMMANDS extended for the group path. - SetupActivity gains a one-time WRITE_SETTINGS grant row (mirrors the overlay/accessibility grants); manifest declares WRITE_SETTINGS. Dashboard: - device-detail "System control" section (any Android panel): volume + this-app brightness sliders always; system brightness + sleep-timeout only when the panel reports can_write_settings, else a "grant on the panel" hint. Sends on release (not drag). Validated live on a non-owner tier-0 panel: dashboard → set_volume 0.75/0.15 → the panel's STREAM_MUSIC volume moved to 11/2 (of 15). 423 server tests green. Closes #160. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(system-control): volume slider reflects real volume + device-owner brightness/timeout [#160] Two fixes from live testing: 1. Volume "doesn't remember" — the slider hardcoded 50 because the panel never reported its current volume. Now DeviceInfo reports media_volume (0..1); a new lightweight device:info socket event lets the panel re-report right after a set_volume (no full re-register / playlist re-push); server stores devices.media_volume; the dashboard inits the slider from it. Validated: dashboard set_volume 0.60 -> panel STREAM_MUSIC 2->9 (of 15) -> stored 0.60. 2. System brightness/timeout on a DEVICE OWNER — was gated only on WRITE_SETTINGS, which an owner doesn't have. A device owner can set those via DevicePolicyManager.setSystemSetting with no grant, so SystemControl now takes that path when isDeviceOwner(), and the dashboard enables the Tier-1 controls when can_write_settings OR tier===2. STPolicy.setSystemSetting added. deviceSocket device_info UPDATE extracted into applyDeviceInfo(), shared by device:register and device:info. Migration: devices.media_volume REAL (additive). 423 server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(system-control): brightness/timeout remember + move controls into a tab [#160] Same "remember what it's set to" treatment as volume, now for brightness + sleep timeout, and the System control section moves off the top into its own "Controls" tab. Reporting (DeviceInfo -> device:info re-report -> devices columns -> dashboard slider init): - system_brightness (read from Settings.System, no permission) + screen_off_timeout_ms. - window_brightness: persisted in ServerConfig (survives relaunch, re-applied on MainActivity launch) so the per-window slider reflects it too. - reportInfoNow() now also fires after set_brightness / set_screen_timeout. Dashboard: new "Controls" tab (any Android panel) holding the volume/brightness/timeout controls; every control inits from the reported value; sleep dropdown preselects the current timeout. Server: +3 additive columns (system_brightness, window_brightness, screen_off_timeout_ms); the device_info UPDATE stores them. Migrations all additive/re-runnable. 423 server tests green. Validated live: set_brightness 0.40 -> stored window_brightness 0.40; volume 0.30 -> 0.33. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
1ebdb1f7a9
|
feat(ota): self-update kill switch — global, per-device, and MDM auto-detect (#166)
Lets an operator (or an MDM) own updates instead of the app self-installing, which on managed panels shows a self-install confirm dialog over customer content (#155). Three layered controls: - GLOBAL (server): config.otaEnabled from OTA_ENABLED (default on). When off, /api/update/check returns update_available:false, reason:ota_disabled_global — the whole instance stops offering updates. - PER-DEVICE (server + dashboard): new devices.ota_enabled column (default 1). When 0, that device is never offered an update (reason:ota_disabled_device). A "Self-update (OTA)" toggle in the device settings flips it via PUT /api/devices/:id. - AUTO-DETECT (Android): UpdateChecker stands down entirely when a foreign device owner (an MDM/DPC) manages the panel — detected via getActiveAdmins() + not being device owner ourselves. Pure client-side, errs safe, needs no server change. The two server gates are enforced server-side so they cover EVERY client version, not just ones with the client-side stand-down. When OTA is off the device still reports its version (dashboard sees state); the MDM/operator owns the actual update. For an MDM-managed fleet (e.g. Pivot/MAXHUB), turn OTA off and let the MDM push the APK — the install-dialog race disappears from every angle. Tests: +2 (per-device gate + a real OTA_ENABLED=false server for the global gate); full server suite 393 pass; Android compiles. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b72e964433 |
feat(dashboard): surface per-device settings PIN + backfill existing fleet (#152)
The server provisions a unique settings-menu PIN per device, but nothing surfaced it — leaving the on-device hidden settings menu effectively unopenable. Show the PIN on the device Info tab (native players only), with i18n across 6 locales. Also backfill a unique 6-digit PIN for already-paired devices that predate the settings_pin column, so the existing fleet isn't locked out (delivered on their next reconnect via the existing device:paired re-send). Idempotent UPDATE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f1fe5d97bd |
feat(dashboard): exit-reason display — Offline annotation + tooltip + filter drill-in + list label
Surface the server's manner-of-death (crashed / clean_exit / silent) as a subordinate qualifier ON the Offline badge (not a 4th liveness state), on both the device list and device-detail. Rides livenessBadge. - Reliability-aware label (contract §10): clean_exit reads plainly on /player (reliable), "(best-effort)" on APK/.wgt. silent = "silent (no signal)". - Honest hover tooltip on every reason (both views), incl. silent = "external/violent: power loss, network, force-stop, or MDM/kill". Never fabricates a reason (no-reason -> plain Offline); state-gated (reason only on Offline); clears on re-online (matches the server). - Filter drill-in: <optgroup> "Offline by reason" -> Offline · silent / crashed / clean exit, matched via a data-offline-reason attribute (Offline·silent = the MDM-killed set — the Bold use case). Existing three-state filter (All/Healthy/Reconnecting/Offline) unchanged. - List label shortened to fit the pill (full text stays on detail; tooltip carries the full honesty both). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a458c8f96a |
feat(dashboard): 3-state liveness badge (consume the patch4 server signal)
The patch4 server derives 3-state liveness (healthy / degraded-reconnecting / offline) and emits it as data.liveness on dashboard:device-status, but the frontend only consumed binary online/offline — the signal was thrown away. Add a shared livenessBadge() helper (utils.js) consumed by both the dashboard device list and the device-detail view (initial render + live statusHandler). Degrades to the binary status when liveness is absent (old payload / plain reconnect+disconnect emits / DB device object) so nothing renders blank; unknown/no-data -> offline default. CSS: healthy=green, degraded=amber+pulse (reads as reconnecting), offline=red — reusing the existing --success/--warning/--danger tokens. Labels in en.js (all locales fall back to en). Frontend-only; server derivation unchanged. 13/13 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
74e7062a33 |
feat(#150): re-adopt UI — restore a removed device's settings onto a re-paired screen
Fallback for when the automatic fingerprint-match restore can't fire (factory reset / new hardware / changed fingerprint). From a device's detail view (UX b): 'Restore from removed device…' opens a picker of the workspace's removed-device snapshots (GET /devices/removed), showing device_name + last_seen/removed_at + restore summary (orientation/timezone/playlist), a Blocked badge, and an Apply action (POST /devices/:id/re-adopt) with a confirm — including an explicit warning that applying a blocked snapshot re-blocks the target. Refreshes the device view on success; handles 404/403/400; empty state. Fingerprint shown truncated on-hover only. Frontend only. Local, no bump/tag. |
||
|
|
ddf1338190
|
Update Italian translation | ||
|
|
0c0a8dd68a |
fix(ota): surface stuck OTA on dashboard + read APK signer correctly on API 28/29 (#139)
Follow-up to the cache/backoff loop fix (
|
||
|
|
a36880b147 |
fix: per-item mute round-trip + multi-zone orphan-zone fallback & warnings
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and regression tests guarding the data contracts. Bug 1 — per-item mute was a no-op end to end: - GET /api/devices/:id dropped the `muted` column from its assignments SELECT, so the dashboard toggle never reflected state (the muted=false case in particular). Column restored to the device payload. - Android player now honours the per-item mute flag for YouTube (initial state + live via the IFrame JS API). Bug 2 — items whose zone_id belongs to a different layout were silently dropped: - Player fallback (web + Android): an orphaned zone_id is recovered into the largest zone instead of vanishing, with telemetry. - server/lib/zone-validate.js is the single source of truth for the orphan rule (zone not in the device's active layout); used by the device payload (per-item `orphan` flag + `active_layout_zones`) and the device list (`orphan_count`). - Assign-time hardening: a stale zone_id (not in the device's active layout) is cleared to null on POST/PUT rather than persisted as a new orphan. - scripts/find-orphan-zone-items.js: read-only sweep for existing orphans. Dashboard warnings (operator-facing, never on the live player): - Per-item badge + reassign affordance, device-list glance, preview banner. - Graceful degradation: the zone selector falls back to /api/layouts/:id so it can't vanish on a stale payload. i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design; count strings interpolate through tn()). Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the data contracts above (muted true/false round-trip, active_layout_zones, orphan flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |