mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
68 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e4c25c39df |
Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as the audience sees it. Invisible while every panel is the normal way up, and actively misleading the moment one isn't — two portrait-mounted panels standing side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy of every video, before the output came out right. It worked, but only after trial and error, and it meant a portrait wall could never reuse content as-is. Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same convention as the per-device orientation setting), the canvas means the physical wall, and the player works out the mapping. The geometry lives in one place, server/lib/wall-geometry.js, because four players have to agree on it to the pixel across a seam. Existing walls need no migration and do not move. Every wall in the field is rotation 0, and that case takes the original expression verbatim on all three players rather than the algebraically-equal centre-based one — the two differ in the last float bit, and a float's worth of disagreement between two panels is a hairline seam down a wall that was aligned yesterday. Pinned by the first test in wall-geometry.test.js and by wall-payload.test.js. While a display is in a wall its panel rotation replaces its own orientation: both describe the same physical fact, so honouring both turned the content twice. #235: a wall replaced its members' cards, so one dead panel of a four-panel wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the live wall and putting it back. The wall screen now lists its panels with live online state, a per-panel screenshot request, and a link to each device's page; the dashboard wall card carries per-member status chips that track socket updates. Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts each panel's viewport maps onto exactly its own rect of wall space, for every rotation, plus a mixed wall and the Tizen player's hand-ported copy executed against the canonical rule. Full server suite green (1260). Not verified here: the Android and Tizen renders on real hardware. Kotlin compiles clean; the maths is shared/tested, the view plumbing is not. |
||
|
|
684e60fc55 |
Offline media on every player, and a revision so the cache can still be updated
Two halves of the same problem. A screen has to keep playing when the link is gone, and it must not keep playing the wrong thing once the link is back. CACHING FOR OFFLINE, on the players that could not: - Tizen cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. tizen/js/media-cache.js caches the media itself to wgt-private (the store Tizen documents as surviving reboots), resumable via Range and If-Range, with the transfer async so a stalled chunk cannot freeze the player. offline.cache moves from "absent" to a runtime claim: a build with no writable private storage still says nothing. - The web player's worker stored only what a single fetch() happened to complete, which on a marginal link is nothing at all — a 200MB asset never finishes in one go and every retry starts from zero. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch is not competing with the video that is currently on screen for the same scarce bandwidth. BrightSign inherits this. STILL UPDATING, which caching quietly breaks: PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every cache keys on that id, so before this the new bytes could not reach a panel that already held the old ones — not until the next refresh, but never. Content now carries a revision, stamped onto each item at send time like widget revs, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath in a published snapshot pointed at a deleted file and web panels 404'd on the item until somebody republished the playlist. The route now also pushes to affected devices, which it never did. Bytes are kept only where they can be built upon: no validator means no safe resume, so the partial is discarded and the attempt backs off as the failure it is rather than re-fetching the same prefix forever. Server needed no new transfer support — res.sendFile already does Range, If-Range and 416. The Tizen cache and the service worker are both driven in Node against fakes, because neither can be exercised without hardware and "the chunks assemble correctly" is not something to discover from a panel showing a corrupt video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
3e6c97ba10 |
Merge: web player capability declaration and the cross-player parity matrix
# Conflicts: # server/ws/deviceSocket.js |
||
|
|
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 |
||
|
|
c4ee7d008f |
web player: declare capabilities at runtime, persist them, and audit all four players
The dashboard offered every control to every display, so a browser tab showed a reboot button that could never work. server/lib/player-capabilities.js defines the vocabulary; this makes the web player actually speak it. The declaration is computed, not constant, because the same index.html is BOTH the browser player and the BrightSign player. system.reboot / display.power / display.resolution / system.self_update are claimed only when BS.hasHost() answers — deliberately hasHost() and not isBrightSign(), since the UA check is also true for a widget built without node integration, which can reach none of them. Screenshots, offline cache, transitions and native sync are each probed the same way. Capabilities were never persisted: the column and the handler did not exist, so a declaration would have been sent and silently dropped. Added the migration and applyCapabilities(). An ABSENT declaration leaves the column NULL so the baseline still applies — several hundred fielded displays declare nothing and would otherwise lose every control at once — while an EMPTY declaration is stored as '[]' and honoured. docs/player-parity.md records every capability against all four players with a reason for each "no", and flags three Tizen baseline errors found while verifying it. Tests: 1109/1109. Both inline <script> blocks in index.html parse clean. |
||
|
|
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
|
||
|
|
ccbd63ba79 |
Stamp the authenticated device on relayed playback progress
device:playback-state was the only relay that forwarded the client's payload verbatim. The workspace lookup correctly used currentDeviceId — the socket's authenticated device — but the object passed on to the dashboard was whatever the player sent, including any device_id it chose to put there. So one device could report playback progress attributed to a different screen in the same workspace, and the dashboard had no reason to doubt it. Every other relay in this file stamps the authenticated id. This one now matches. 882 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
a3b668d32f |
Treat an empty device_info as "nothing new", not as "forget what you know"
Every web and BrightSign player nulled seventeen of its own device columns every five minutes.
The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has nothing
new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is a blind
full-row overwrite with no per-field presence check, so it bound undefined for every column.
better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and the row was
quietly emptied: android_version, app_version, screen_width/height, render_*, ota_status and
attempts, tier, the four capability flags and the four volume/brightness columns.
Android never hit it, because it always sends the full object. So this degraded exactly the client
family that cannot be inspected any other way — a browser player has no adb, and the dashboard row
is all there is. Fleet view, resolution diagnostics and any version-based logic read blank for them,
which also makes evaluating a browser-based platform look worse than it is.
The surrounding code already anticipates the refresh shape: recordReconnect and persistIdentity are
both gated behind `if (!isPlaylistRefresh)`. This call was the one that was not.
5 tests, including one pinning the driver behaviour the bug depended on — undefined binds as NULL
rather than throwing, which is why this was a silent five-minutely wipe instead of a loud error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
4cc750ba3a |
Text widgets: stop losing text off the bottom, and show an edit without an app restart
Two separate faults in the same widget, both reported on #234. 1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to bottom and disappears. It dont fit." The content now gets a wrapper and an overflow mode: fit (default) shrink until it fits — a NO-OP when the content already fits, so it rescues widgets that are currently losing text without changing ones that are fine scroll pan through it on a loop with a pause at each end, for content genuinely longer than a screen where shrinking would make it unreadable clip the old behaviour, kept because a designer-positioned layout may deliberately run past the edge and must not be rescaled underneath its author Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts arriving late is the classic cause of a fit computed against the wrong height. 2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves live config, but the player deliberately keeps a widget's WebView while its URL is unchanged (re-navigating every duration is a visible flash and destroys widget state — a half-typed directory search, scroll position). Editing changes the content, not the id, so the URL never changed and the reuse check always hit. The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so the URL differs exactly when the content differs — and only then, so the anti-flash reuse still holds for untouched widgets. The rev is refreshed at send time rather than read from the published snapshot, because a widget edit does not republish the playlist. Editing a widget also now pushes to the displays showing it, instead of notifying nothing at all. 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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 |
||
|
|
2bcc46bc26 |
Give each player install its own identity
The web player derived its fingerprint entirely from hardware traits: user agent, screen geometry, colour depth, timezone, core count, platform and a canvas raster. Every one of those describes a model rather than a unit, so two identical panels produced the same value and the server, which matches on that value globally, treated them as one device. Two UniFi Pro Displays at different sites both produced web-m73u8w-5f; the second could not be brought online, and the row ended up shared, each display evicting the other every thirty seconds. The identity a player presents is now hardware plus a random per-install salt kept in localStorage, so two identical panels differ from their first connection. This is what the Tizen player has always done; the web player is brought in line with it rather than given a new scheme. The hardware value is still sent, but only as a hint, and only to move a caller that has ALREADY authenticated with a device id and token onto its own row — which is how an existing player carries its identity across this change. A caller without credentials never resolves through it, however few rows it appears to match: one row recorded does not mean one display exists, and that distinction is the whole bug. Such a caller is provisioned a new device, which costs one pairing code and cannot be wrong. Older clients are unaffected. They send no hardware value, so they take the exact-match path exactly as before, and both keep working: the APK's fingerprint already includes ANDROID_ID and the Tizen player's is already a stored random id, so neither ever shared an identity between units. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
a93f65b20a |
Only store a device fingerprint against a device that still exists
A player that reconnects after its row was deleted sends the id it still has cached. device_fingerprints.device_id has a foreign key to devices(id), so writing that id back fails the constraint. The throw was caught, which is why this looked harmless, but the catch abandons the whole fingerprint block: last_seen is not updated, the reinstall link is not made, and the settings restore never runs. That restore exists specifically for the post-delete re-pair, so the failure landed exactly where the feature was meant to help and a re-paired panel came back with its orientation, name and playlist reset. Production shows 37 of these, timestamped identically to the "sending unpaired" log lines — the same event seen from the other side. The incoming id is preferred, then whatever is already stored, and only an id that still resolves is written; otherwise NULL, which the column allows and which ON DELETE SET NULL already leaves behind. The INSERT path a few lines below had this guard; the UPDATE was missed, and it is the one that fires. Tests cover the deleted-id reconnect, that last_seen still advances, and that live ids are unaffected. One asserts the raw unguarded statement really does raise FOREIGN KEY constraint failed, and another asserts the guard is present in the handler itself, since the others exercise a mirror of that statement. Also ignores *.sqlite / *.sqlite3, which the existing *.db rules missed. 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> |
||
|
|
866e35a2b2 |
Clear a device's OTA rate state when it proves its identity
The update check is deliberately unauthenticated — every client version has to be able to ask, including old ones that never learned to send a token — and it keys the rate breaker on the caller-supplied device_id. Keying on IP is not available either: the fleet SNATs behind one address, so per-IP would collapse a whole site into a single bucket. The result was that the bucket belonged to whoever cited the id rather than to the device that owns it. A handful of requests naming a panel's UUID left that panel in rate-backoff, un-updatable for up to half an hour at a time and renewable indefinitely, while every other device stayed healthy. Rather than adding auth (which would strand old clients) the state is now self-healing: when a device registers on the /device socket with a valid device_token its bucket is cleared. Noise is still possible, but it now lasts until the panel's next genuine reconnect instead of as long as someone keeps poking. This is not an escape hatch from the breaker's real job. A device stuck in an update loop is re-registering legitimately, and clearing its rate state on each genuine reconnect is what a healthy device looks like; the loop protection that matters is the download guard. The version-keyed bucket that covers old clients sending only ?version= is a separate namespace and is deliberately not reachable this way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b255f2bfe1 |
Resolve proof-of-play references instead of trusting the reported id
Players replay a cached playlist, so the id reported on play_start can outlive the row it names. play_logs.content_id carries a foreign key to content(id), and the id went straight into the INSERT — so deleting a piece of content made every subsequent play of it throw, and the whole event was discarded by a catch that logged no identifiers. On production this fired roughly 360 times in six hours and wrote zero rows in 24h: Reports was recording nothing at all, for everyone. Widgets had a quieter version of the same bug. play_logs.widget_id exists and was never written, so a widget play could not be attributed even when it did insert, and play_end matched on content_id alone and so could never close a widget's open row. The reported id is now looked up before use and written to whichever column it belongs to. An id matching neither degrades to null references rather than losing the event — content_name still records what played. A play event for a device that does not exist is still refused; that foreign key is a real invariant, not an obstacle. play_end matches on either column, and breaks ties on id: started_at has second granularity, so two plays inside one second tie on it and the wrong row could be closed. The new tests caught exactly that as flakiness before it was pinned. The catch now logs the event, device, content and zone. Without them this was undiagnosable in production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c4b5a8679e |
refactor(auth): centralise session token resolution across manual verify sites
Six places verified a session JWT inline instead of going through requireAuth, each repeating a slightly different subset of its checks. Introduce resolveSessionUser() in middleware/auth.js as the single definition of "this token is a usable session, and here is whose it is", and route all of them through it: the three /api/status token routes, the screenshot route, the content-reference gate, and the /dashboard socket handshake. requireAuth is now a thin wrapper over the same helper, so the two cannot drift. Also: - Give the pre-TOTP token a distinct audience so it is redeemable only through verifyMfaPendingToken (POST /api/auth/totp/verify). verifyToken refuses any token carrying an audience, so a token minted for one purpose cannot be redeemed on another path. - The dashboard socket handshake now takes userId/userRole from the live users row rather than from the token claim, so role changes take effect on the next connection instead of riding the token's remaining lifetime. - Add test/session-token-resolution.test.js covering all six surfaces, including the socket handshake. Every call site keeps the status code and error body it returned before. Net query cost: the content-reference gate and the socket handshake each gain one users-by-id lookup (the same one requireAuth already does per request); the other four are unchanged or replace an equivalent lookup. In-flight pre-TOTP tokens are invalidated by the audience change; they live 5 minutes, so the window is a re-login at worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
ba0663edc1
|
fix(pairing): close deferred-offline reclaim race + idempotent same-code adopt (#192)
A fresh unclaimed player that reconnects (same fingerprint) INSIDE the server's ~5s deferred-offline grace hit a false 'active on another connection' reclaim reject, then collided on UNIQUE(devices.pairing_code) on the fall-through INSERT and wedged unclaimed with no content. Real trial customer (web player) hit it. server/ws/deviceSocket.js: - Fix A (guard): gate the liveConn reclaim reject on !inDeferredOffline (pendingOfflines.has(id)). A device mid-deferred-offline is a zombie, not live, so a same-fingerprint reconnect is a legit reconnect, not a hijack. A genuinely live socket (never disconnected -> no pending-offline) still rejects a cloned fingerprint -> anti-hijack boundary preserved (documented). - Fix B (idempotency): when the unclaimed old row holds the SAME pairing_code the reconnecting player presents, ADOPT/refresh it (mirror the claimed-reclaim path, but no device:paired) instead of INSERT-colliding. Differing-code case unchanged. - deferOffline is NOT shrunk (it exists to prevent transient-blip flapping). server/player/index.html: - The cold-boot flap source: an unfiltered pageshow handler ran verifyLivenessSoon() on every load, opening+registering a socket early, which the boot connect() then tore down and rebuilt (connect->register->disconnect->reconnect). Guard it with ev.persisted (mirror the pagehide guard) so only real bfcache restores trigger it. server/test/pairing-race.test.js: - Forces the race against the real socket server (log-gated reconnect inside the deferred-offline window), asserts no false reject / no UNIQUE collision / single claimable row; + a hijack case asserting a cloned fingerprint on a genuinely-live display is still rejected. Web- and android-shaped fingerprints. Fails 2/4 on pre-fix code, 4/4 with the fix. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
51b0b006b1
|
fix(pairing): reinstalled panel reclaims its device row instead of being blocked [Bold] (#180)
Bold Media Group's fleet broke on the 1.9.3->1.9.6 upgrade. Their MDM does an
uninstall/reinstall (app data wiped), so the player registers with
{ pairing_code, fingerprint } and NO device_id and shows a pairing code — but the
dashboard reported "code does not exist". Deleting the device_fingerprints row fixed
it, which pinpointed the fingerprint-reclaim guard in server/ws/deviceSocket.js.
Root cause: the reclaim guard was
`stillAlive = !!liveConn || secondsSince < reclaimSettleSeconds; if (stillAlive) reject`.
On an in-place reinstall the old row heartbeat seconds ago, so `secondsSince < 300` is
ALWAYS true -> it emitted device:auth-error and returned BEFORE the pairing_code INSERT,
so the code the player displayed never existed server-side.
The settle window's real purpose was to REMATCH an existing fingerprint back to its
device row on reinstall — not to force a fresh re-pair. So the fix keys off claim status,
not the timer (server-only; no APK change — reviewed and confirmed unnecessary):
- Reject ONLY when the old row has a genuinely LIVE socket (liveConn) — the real anti-
hijack boundary. Unchanged.
- CLAIMED old row (user_id set) -> RECLAIM it regardless of the settle window: reuse the
row, rotate the token, emit device:registered{online} + device:paired. The panel returns
straight to paired (no operator re-pair, no orphaned duplicate row), preserving name /
claim / playlist / content. device:paired drives the app off the pairing screen, so the
fresh code it showed is irrelevant.
- UNCLAIMED old row -> fall through to the pairing_code path and PROVISION FRESH with the
shown code (reclaiming would leave a stale/null code -> "code does not exist"). #150
relinks the fingerprint to the new row.
`reclaimSettleSeconds` is now vestigial for this path. Trade-off: a fingerprint-only reclaim
of a CLAIMED-but-offline device is no longer delayed ~300s — not a new attack class (the old
code already granted it once the window elapsed); liveConn remains the hard boundary. Truly
closing that window without a re-pair needs client keystore attestation (a future APK).
Also fixes a latent crash this newly exercises: middleware/subscription.js getUserPlan()
dereferenced an undefined user in its else branch ("Cannot set properties of undefined
(setting 'trial_active')") when the user/plan JOIN missed. Under the claimed-reclaim path
that ran checkDeviceAccess->getUserPlan, the throw was swallowed by the reclaim try/catch and
silently dropped the device to provision-fresh. Guard: `if (!user) return null`.
Tests (server/test/fingerprint-reclaim.test.js):
- NEW: a CLAIMED reinstall reclaims the SAME row, emits device:paired, creates no duplicate,
keeps the fingerprint linked — regardless of the settle window (the Bold repro, fixed right).
- NEW: recent heartbeat + no live socket, UNCLAIMED -> provisions fresh with the shown code.
- NEW: a LIVE old socket still rejects and creates no new row (security preserved).
- Updated the #143 gone-device test to expect provision-fresh for an unclaimed row, and the
log-noise assertion to the "reclaim rejected" message.
465/465 server tests pass. Server-only: NOT deployed, no version bump, Android untouched.
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>
|
||
|
|
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>
|
||
|
|
d474122334 |
Merge origin/main into feat/android-hidden-settings-menu
Resolved conflict in server/db/database.js: kept both settings_pin migration (our change) and device_settings table migration (main's #150). |
||
|
|
58f27d56e8 |
fix(android): server-provisioned settings PIN replaces hardcoded 0000
- Remove stray brace that broke compilation (MainActivity line 985) - Server generates unique 6-digit PIN per device during pairing - PIN stored in encrypted SharedPreferences (ServerConfig.settingsPin) - Fallback: generate random PIN locally if server doesn't send one - Include settings_pin in device:paired on pair + reconnect - DB migration: settings_pin column on devices table - Hint changed from hardcoded 0000 to generic 'PIN' string |
||
|
|
8ad2258e7c |
feat: app-ending signal (exit-signal contract v1) — server + APK + .wgt + /player
Best-effort "last gasp" so Offline is annotated with WHY it went away — completing the liveness story.
Categories: crashed (client uncaught-exception), clean_exit (client confident lifecycle-end, best-effort),
silent (SERVER-inferred by absence — the honest catch-all for violent/external death incl. force-stop/MDM).
SERVER:
- device:exit socket handler + token-authed beacon POST /api/device/exit (reliable-on-unload). Both gated
by liveness.sanitizeExitReason (honesty: only crashed/clean_exit accepted; 'silent'/unknown rejected).
- offline_reason/offline_reason_at/offline_detail columns (additive migration). Clear-on-online (a reason
is always THIS session's); offline transition COALESCEs to 'silent'. Pure annotation — offline detection
and #148/liveness are untouched. Offline dashboard emits carry offline_reason + client_type.
CLIENTS (canonical {reason,detail} shape):
- /player: window error/unhandledrejection + pagehide(persisted=false) -> sendBeacon.
- .wgt: same + BACK-key exit -> socket.emit + sendBeacon.
- APK: global UncaughtExceptionHandler -> crashed (blocking beacon, chains to default); Service.onDestroy
-> clean_exit (socket + bounded beacon). New ExitSignal.kt. onStop/onPause NOT wired (background != exit).
Proven (Phase 3): per-category classification, nothing misclassified, external kill -> silent (never
clean_exit), backgrounding emits no false exit, #148/reconnect-vs-exit intact. 382/382 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
4cf156d4a0 |
feat(server): v4 liveness CORE pass — uniform heartbeat-ack + ack-gap + dashboard liveness + identity
Server-side keystone: the server now honors the v4 liveness contract uniformly across the MIXED fleet (v4 + old pre-v4 + disconnected), all three clients depending on it. - UNIFORM heartbeat-ack: emitted from the single shared device:heartbeat handler (uniform by construction; no per-client/per-path branch), BEFORE the auth guard so a known device's watchdog stays armed. Harmless to old clients (they ignore it). - RECONNECT-WINDOW ack-gap fix (ackableHeartbeat): ack a KNOWN device (authed socket OR a device_id that resolves) even mid-reconnect; NOT anonymous/never-authenticated sockets (degrade-safe); identity-agnostic. No state mutation before requireDeviceAuth (auth surface unchanged; device_ids are uuidv4). - DASHBOARD LIVENESS (deriveLiveness): server-derived, VERSION-AGNOSTIC Healthy/Degraded/Offline from signals every client sends (socket presence, heartbeat age, reconnect frequency); no client status-push. - IDENTITY CAPTURE (capture-don't-act): client_type/client_version/platform/contract_version columns; degrades to legacy/unknown for old clients; NEVER breaks register. - A-BUCKET FIX (QA): recordReconnect + persistIdentity gated on !isPlaylistRefresh (a ~45-60s refresh is not a reconnect/new identity — matches #134), and the identity write is change-detected — closing the WAL write-amplification (A1) and the benign-refresh -> false-"Degraded" (A2) regressions. New lib/liveness.js (pure helpers, unit-tested). 30 new tests (uniform ack, ack-gap, mixed fleet, identity capture, cross-client conformance, refresh-gate reproduce-then-prove); 366/366 total. OTA artifact-availability is a separate concern (out of scope). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2ba06e98ec |
feat(#150): preserve per-device settings across delete+re-pair (fingerprint-keyed)
Delete+re-pair mints a new device row whose INSERT omits every setting, silently resetting orientation/name/playlist/etc to defaults (Bold MDM churn). Add a fingerprint-keyed device_settings table (no FK to devices -> survives the cascade): snapshot on DELETE, auto- restore on fingerprint-match re-pair (relinking the fp to the new id), operator re-adopt API (GET /devices/removed + POST /devices/:id/re-adopt) for the changed-fingerprint case. Purge on workspace/user/org deletion (no cross-tenant bleed). Orientation enum-validated on PUT + restore. blocked preserved (re-enforced by the register kill-switch). Wall membership deferred (TODO). Backend only — frontend re-adopt UI NOT built (awaiting API review). Local only, no bump/tag. |
||
|
|
e1ce36b2a8 |
fix(#148) patch2: per-device session-settle debounce — absorb duplicate-socket storms
Field-safe SERVER net. A device opening duplicate/rapid sockets (the APK duplicate-socket bug, separate track) currently churns through evictions during the reconnect-throttle's 30s post-restart WARM-UP (only the hard ceiling 20 applies then, so an 8-in-9s burst passes undamped and each new socket evicts the prior). This makes the server absorb it: a thrashing PAIRED device converges to ONE stable connection and stays online. - lib/session-settle.js (decision only; bounded, swept): shouldHold(deviceId, incumbentAlive) — true only when a socket was accepted for this device within SESSION_SETTLE_WINDOW_MS (config, default 2500ms) AND the incumbent is alive. Warm-up-independent. - deviceSocket register gate (just before evictPriorSocket): if a LIVE incumbent exists and we're inside the window, SOFT-REFUSE the new socket (device:throttled reason=session_settle + disconnect) and keep the incumbent; else accept + evict + (re)arm the window. - LIVENESS SAFEGUARD (load-bearing): only hold when the incumbent socket is actually in the /device namespace — a dead/half-open incumbent is replaced, NEVER stranding the device (max hold is the 2.5s window from the incumbent's accept, then any new socket is accepted). - Soft refusal, NEVER a quarantine (reuses patch1's paired-safe philosophy); single-session enforcement intact for a legitimate move; unpaired/abusive flapping still caught by the existing limiters. O(1), no loop impact. Tests (liveness first-class): live incumbent holds + DEAD incumbent replaced (not stranded); storm of 6 sockets converges to ONE, stays online, not quarantined (during warm-up); single- session move past the window replaces cleanly; unit decision + bounded sweep. The evicted-socket-rearm test shrinks its settle window so it still exercises the eviction path. Suite 336/336. |
||
|
|
8809007d9e |
fix(#148) Item 1: exempt paired+authenticated devices from the flap-limiter quarantine
The flap-limiter could 30-min quarantine a PAIRED, legitimate device on reconnect churn.
Behind Bold's single SNAT IP a repeated edge flush -> every device reconnects -> trips flap
-> quarantined -> a recoverable blip becomes a SUSTAINED FLEET-WIDE LOCKOUT we caused.
check(key, now, {paired}) now skips (and clears) the quarantine escalation for a paired
device — it still gets the brief soft cooldown if it truly hammers, but never the long
lockout. The register gate computes paired = device_id && validateDeviceToken(...) (a
matching STORED token, false for missing/mismatch) so a spoofed device_id can't claim the
exemption; unpaired/anon flapping (attacker / unprovisioned hammering) still quarantines.
Tests: unpaired flapper still quarantined; paired never quarantined (soft cooldown only);
paired creds RELEASE an in-flight quarantine; N paired devices from one SNAT IP all admitted
on reconnect and never quarantined across repeated flush cycles.
|
||
|
|
317754376c |
fix(#146) P0: auto-quarantine is in-memory + time-limited, never a DB block
The flap limiter's auto-quarantine used to run `UPDATE devices SET blocked = 1` — a PERMANENT, human-cleared block on an automatic trigger. A stuck-then-recovered device stayed dark until someone noticed. - Removed the auto-write from ws/deviceSocket.js. devices.blocked is now written ONLY by an operator (dashboard endpoint / direct SQLite). - Quarantine moved into lib/flap-limiter.js as IN-MEMORY, TIME-LIMITED state: after connectRateQuarantineTrips trips in a window the identity is quarantinedUntil = now + connectRateQuarantineMs (new, default 30m); check() then refuses cheaply with reason:'quarantined' and AUTO-CLEARS when the window passes. Safe in-memory now that Item A ended the restart loop, and a self-healing auto-action must not survive as a DB row. - Log quarantine START once; repeat refusals go through the coalescer. Stale "-> blocked=1" comments updated. - connectRateQuarantineTrips=0 still disables it. Tests: quarantine engages after N trips, refuses cheaply during the window, auto-clears after connectRateQuarantineMs; and an integration flapper is quarantined while devices.blocked stays 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4bda49cf60 |
fix(#146) E: log/write self-protection — coalesced logs, batched telemetry, bounded maps
Don't let telemetry/logging cook the loop under a storm.
- lib/log-coalescer.js: dedup+count high-frequency lines, flush ONE summarized line per
key per window ("[loop-lag] band=critical (x47 in 30s)"). Bounded buffer (auto-flush
at MAX_KEYS). Applied to the loop-lag "still loaded" line (band CHANGES stay immediate),
the per-request OTA check line, and "Device reconnected".
- loop-lag: event_loop_lag rows are BUFFERED and batch-inserted on a flush interval
(was a synchronous INSERT per sample); the buffer is bounded (drop-oldest). Its
retention prune now rides the Item-A chunkedDelete so this table can never repeat the
status_log bloat-then-freeze. /api/status still reads in-memory current (real-time
band unaffected).
- Bounded the previously un-evicted per-device Maps: content-ack limiter gets an idle
sweep (started in server.js); status-log-writer.lastWritten is capped (drop-oldest;
it only suppresses a redundant consecutive row, so eviction is safe).
Tests: N identical lines -> one counted line; single line verbatim; coalescer buffer
bounded under a distinct-key flood; content-ack Map swept of idle buckets.
loop-lag-integration updated for the batched-insert cadence. Suite 266/266.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
97d489223f |
fix(#146) D: operator block — close the device_id-less gap + dashboard toggle
- Enforcement (deviceSocket): resolve identity ONCE via the SNAT-safe chain and check
blocked against the RESOLVED device_id (device_id directly OR fingerprint->device_id),
so a blocked device that reconnects WITHOUT a device_id is still caught — the old
"if (device_id)" gate let a device_id-less reconnect slip past. Still the first gate,
before flap/throttle/DB/playlist. Nulling the token still does NOT block (it
re-provisions) — the blocked column is the lever.
- Dashboard toggle: POST /api/devices/:id/{block,unblock} (write-gated + workspace-scoped
via checkDeviceOwnership) writes devices.blocked; takes effect on the device's NEXT
register with no restart. api.js + a Block/Unblock button in device-detail.js.
- Outage procedure documented in-code: direct SQLite
"UPDATE devices SET blocked = 1 WHERE id = <id>" works with the dashboard down.
Tests: blocked refused at handshake with no playlist build; device_id-less reconnect
with a mapped fingerprint still refused; unblock effective on next register, no restart.
Suite 262/262.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
9e3222a503 |
fix(#146) B: sustained flap-rate limiter (the trigger fix), SNAT-safe identity chain
The #142 burst throttle (5/10s) misses a device flapping every 3-5s (~2-3/10s) — yet each cycle is an expensive register+build+acks and one status_log row (the spiral trigger). Now that Item A ends the restart loop that used to wipe in-memory throttle state every ~40s, an in-memory sustained limiter can finally bite. - lib/device-identity.js: SNAT-safe identity resolution — device_id -> fingerprint (map via device_fingerprints -> device_id, else raw fp) -> device_token -> ONE bounded global anon bucket. NEVER IP (the fleet SNATs to 10.10.10.1). An unidentifiable client is still bucketed (collectively) so an anon flood is capped, never unthrottled. - lib/flap-limiter.js: per-identity connect-frequency over a long window (CONNECT_RATE_WINDOW_MS=5min, CONNECT_RATE_MAX=20; anon bucket cap 60). Over the rate -> refuse + disconnect (cheap). Bounded by an idle sweep (anon bucket never swept). Optional auto-quarantine: a device_id-resolved hard flapper -> blocked=1. - Wired at the device:register gate BEFORE fingerprint tracking/throttle/DB/build, skipping same-socket playlist refreshes. Sweep started in server.js. Tests: 4s-flapper refused after the window max; 60s-normal never; two device_ids independent (never IP); device_id-less bucketed by fingerprint; neither id nor fingerprint capped via global anon; idle sweep preserves the anon bucket. Suite 254/254. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cbf81a05a3 |
fix(#146): crash-hardening — one device's handler throw can't take down the fleet
Found in the alpha load test: client-chosen pairing codes collide by birthday paradox, the provisioning INSERT hit UNIQUE(devices.pairing_code), the SqliteError threw out of the (synchronous) socket handler -> uncaughtException -> logFatalAndExit -> the WHOLE server exited and every device dropped. The colliding flood crash-LOOPED the container (2 restarts). Two layers, same "one device can't take down the fleet" theme as #142/#143/#144: 1. Narrow (deviceSocket.js): wrap the device:register provisioning INSERT in try/catch — a UNIQUE pairing_code collision (or ANY db error) rejects THAT registration (device:auth-error -> client retries) instead of throwing. currentDeviceId/authenticated now set only AFTER the row exists (no half-auth socket on failure). 2. Broader (lib/safe-socket.js): protectSocket() overrides socket.on per connection so any handler throw is caught, logged (event + id + stack), the socket told, and DISCONNECTED — per-CONNECTION fail-fast, not whole-PROCESS. We don't keep serving a connection from possibly-half-mutated state (honors the existing fail-fast intent), we just contain it to "one device reconnects" (a non-event after beta5). Wired into both the /device and /dashboard connection handlers; auto-covers future handlers. Audited first: no handler throws as control flow, so blanket-wrapping is safe. Tests (mutation-verified, fail without their fix): - register-insert-crash.test.js: a pairing_code collision AND a general bind error each reject-one-device with no uncaughtException; server keeps serving. - socket-handler-isolation.test.js: a throwing handler disconnects only that socket; the server + other sockets stay alive. Full suite 243/243. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
81e7d58099 |
fix(#146): reconnect/heartbeat storm containment (beta5)
Second head of the OTA-loop root cause (#144), on the connection/heartbeat layer: unbounded device-driven work with no circuit-breaker. Symptoms in Bold prod — devices shown OFFLINE in CMS while online+playing, loop-lag simmer (p99 300-1145ms), device_status_log grown to 1.1M rows. False-offline (two causes, both fixed): - evicted-socket re-arm race: evictPriorSocket runs before registerConnection, so the evicted old socket's disconnect armed a fresh offline timer for a just-reconnected device. Tag evicted socket ids and bail in the disconnect handler (ws/deviceSocket.js). - heartbeat checker false-positive: a device with a live socket in /device is UP even if its in-memory lastHeartbeat is stale under lag; skip it instead of marking offline (services/heartbeat.js). Storm containment: - batched/coalescing device_status_log writer (lib/status-log-writer.js): net state per device per flush, breaking the storm->bloat->slow-write->lag loop. - newest-N-per-device row-count cap in the global sweep (db/database.js): hard bound regardless of churn; trims the existing 1.1M backlog on the first sweep. Per-device prune unified to statusLogRetentionDays (was hardcoded 7d). - reconnect-throttle idle-bucket sweep (lib/reconnect-throttle.js): the #142 throttle already existed; added the memory-bound sweep it lacked (wired in server.js). No second breaker. - cosmetic: cap the OTA breaker level counter (lib/ota-breaker.js). - best-effort status-log flush on the crash path (server.js). Tests: load harness (test/reconnect-storm-load.test.js) proves breaker engage, clean offline-clear, no-throttle-on-normal-reconnect, batched writes, bounded loop-lag; cause-1 re-arm race proven with teeth (test/evicted-socket-rearm.test.js). Both mutation-checked (fail without their fix). Full suite 240/240. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8d37c7f5ff |
fix(#143): notify a screen it's paired on reconnect (recovery-critical)
Bold: screens sit on the Connect page showing the server URL = paired server-side
but never told, so the app never starts playing.
Flow / gap (Step A):
- CLIENT leaves the Connect page ONLY on the 'device:paired' event — web player
(player/index.html) hides the setup screen; Android ProvisioningActivity.onPaired
launches MainActivity + finish(). That event is the sole signal.
- SERVER pushes 'device:paired' to the device's room from POST /api/provision/pair
(server.js) at pair time — but ONLY reaches a LIVE socket then. The normal
device_id reconnect path emitted device:registered + device:playlist-update but
NOT device:paired. So a screen paired while disconnected, or that reconnects after
pairing (exactly the screens cycling on the Connect page), is paired server-side
(user_id set, receiving playlists) yet never gets device:paired -> stuck on Connect.
Fix (server-only, uses the EXISTING client listener — no client update needed, which
matters because we can't push a client update to stuck screens): on the device_id
reconnect, if the device is paired (user_id set), re-emit 'device:paired'
{device_id, name}. Push-on-pair (server.js) already covers the live-at-pair-time
case; this covers paired-then-reconnect. A paired screen now leaves Connect and
plays on its next reconnect with no client change and no manual re-pair.
Tests (port 3989, real flow): provision -> pair via /api/provision/pair (socket
closed) -> reconnect RECEIVES device:paired (+name +playlist) — the stuck-screen
repro; an unpaired device gets NO device:paired (stays on the pairing flow); the fix
reuses the existing device:paired event (no new protocol). Full suite green serial
AND parallel (220);
|
||
|
|
e73428182d |
fix(#143): fingerprint-reclaim stuck loop — reclaim by runtime liveness, throttle log
Bold beta1: three devices spam "Fingerprint reclaim rejected ... device active (status=offline, ~2500s since heartbeat, liveConn=false)" twice/~2s indefinitely — contradictory: gone by every signal yet treated as active. Root cause (NOT a missing clear — corrected the hypothesis). The reject condition was `liveConn || status==='online' || secondsSince < RECLAIM_GRACE_SECONDS(24h)`. For the observed devices liveConn=false and status=offline, so the ONLY true term is `secondsSince < 24h` — an effective 24h CALENDAR grace, not a stale flag. Audited the clears: liveConn (deviceConnections) is removed on the debounced disconnect (heartbeat.removeConnection) AND the offline_timeout sweep (deviceConnections.delete); status is set 'offline' on both. liveConn=false + status=offline PROVE the clears ran — there is nothing stale to clear. The 24h time gate (mislabeled "device active") blocked a legitimately-gone device from reclaiming for up to 24h, so it retried every ~2s forever-in-practice. The "twice per ~2s" is two reclaim ATTEMPTS per cycle (client reconnect + re-pair-on-auth-error), each hitting the single console.warn — not double-logging in one attempt. Fix: - Decide "still alive" from RUNTIME signals: `!!liveConn || secondsSince < reclaimSettleSeconds`. A device with no live socket and a heartbeat older than the settle window is gone -> reclaimable. A live (or just-seen) device is still rejected, so reclaim-abuse protection holds. NOT just ignoring "active" — it fixes WHY it was stuck (the 24h gate). RECLAIM_SETTLE_SECONDS default 300 (was 24h). SECURITY TRADEOFF flagged in config: shortens the anti-fingerprint-theft window; raise to re-tighten. Tuning guess to validate vs Bold. - Log throttle: the deferral logs at most once per device per RECLAIM_REJECT_LOG_ WINDOW_MS (default 60s) — collapses the double-log + the per-2s flood (same discipline as the content-ack shed log). Cleared when a reclaim proceeds. Recovery of the 3 wedged devices (2febcaa9, 1984694c, 139159eb): they SELF-HEAL on their next reclaim attempt (~2s) once this ships — their heartbeats are ~2500s stale (>300s settle) and liveConn=false, so the reclaim now succeeds. No operator SQL needed. Tests (port 3988): gone device reclaims; live device still rejected; clear-on-leave (disconnect clears liveConn -> stale device reclaims); deferral log <=1 per window. Full suite green serial+parallel (217). reconnect-throttle.js, the |
||
|
|
404c3301dd |
fix(#143): enforceable device block + fix the null-token auth short-circuit
Highest-priority #143 item (operator finding from Bold): nulling a device's token
did NOT lock it out — device 75c2a08a immediately reconnected and saturated the
loop. Two distinct defects:
1. Auth short-circuit (the cause). device:register used
if (device.device_token && !validateDeviceToken(...)) { reject }
so a NULL/empty STORED token made the guard falsy -> validation SKIPPED, and the
next block even MINTED a fresh token and persisted it. Nulling a token thus
RE-PROVISIONED the device instead of locking it out. Fix: drop the
`device.device_token &&` guard -> `if (!validateDeviceToken(device_id, device_token))`
(validateDeviceToken already returns false for null-stored/missing/mismatch), and
remove the legacy "mint a token for a null-token device" path (the re-provision
vector). An already-provisioned device (every row, incl. 'provisioning', is created
WITH a token) presenting null/empty/invalid is now REJECTED + disconnected.
The first-pairing seam is unaffected: a brand-new device has NO device_id and goes
through the pairing_code branch (which mints id+token) — a different code path.
2. No server-side kill switch. Added a `blocked` column (devices.blocked INTEGER
NOT NULL DEFAULT 0; schema.sql + a database.js migration). The block is the FIRST
gate at the top of device:register — before the fingerprint block, the reconnect
throttle, any DB writes, or playlist build — so a blocked device's socket is
refused immediately (auth-error 'Device blocked' + disconnect, zero further work).
It does NOT rely on null-token (the thing that failed). The row is re-read every
register, so a DIRECT SQLite edit takes effect on the device's NEXT reconnect with
NO server restart. Operator statements (dashboard-down, hand-edit):
block: UPDATE devices SET blocked = 1 WHERE id = '<device_id>';
unblock: UPDATE devices SET blocked = 0 WHERE id = '<device_id>';
Tests (port 3987): nulled-token provisioned device is REJECTED (75c2a08a repro);
blocked=1 refused at the first gate (no register/playlist); unblock reconnects;
first-pairing still works; normal valid-token device unaffected. Full suite green
serial AND parallel (213); reconnect-throttle.js + the
|
||
|
|
dbac699854 |
fix(#143): content-ack flood control — per-device rate budget + loop-lag valve
#142's content-ack dedup is insufficient: a device cycling 2-4 content IDs makes every ack look unique so dedup never fires, while aggregate volume from ~30 devices saturates the event loop (the #142 reconnect throttle kept the server responsive, which is how this was even observable). Folded ONE control on the content-ack path (no competing limiters; reconnect- throttle.js untouched) in lib/content-ack-limiter.js: - Step 1 — per-device RATE budget: caps TOTAL non-duplicate acks per device per window regardless of differing content_id (the case dedup misses). Over budget = DROP silently (the per-ack log+emit is the cost); log ONCE per device per window when shedding starts. Keeps the #142 dedup (dedup'd repeats don't consume budget). Per-device, in-memory, resets on restart (modeled on lastPlayLogAt; does NOT reuse reconnect-throttle's ban-semantics bucket). Env (TUNING GUESSES, validate vs Bold's fleet): CONTENT_ACK_MAX_PER_WINDOW=20, CONTENT_ACK_RATE_WINDOW_MS=10000 (=2/s, above legit ~<=1/s, below the flood). - Step 2 — global pressure valve: reuses the #142 loop-lag band (+ its hysteresis, no second control loop). Under CRITICAL band, shed content-acks even for an in-budget device; reconnects + dashboard/HTTP are ALWAYS processed; a healthy device in a non-critical band is never touched by the valve. Valve open/close logged once at the band edge in services/loop-lag.js (not per shed message). Tests (unique ports 3985/3986, not the 3982/3983/3984 set): - unit: the #143 regression (cycling ids evading dedup IS rate-limited), under/over budget, dedup still works + doesn't consume budget, valve sheds in-budget under critical while normal is untouched, rate precedence, window reset, per-device isolation. - integration: socket flood is capped to budget with a single shed-start log; under-budget passes every ack; valve OPEN sheds content-acks while a reconnect + /api/status still succeed. Full suite green serial AND parallel (208 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
15448d1c5d |
fix(#142): dedup repeated content-ack reports (secondary load)
device:content-ack logged + emitted every message, so a device repeatedly reporting the same "content <id>: ready" (observed from an older app version) added avoidable load per message. - Suppress identical (device_id, content_id, status) reports within config.contentAckDedupMs (default 10s), modeled on the lastPlayLogAt throttle. A status change has a different key and passes immediately; a fresh report after the window passes too. In-memory, resets on restart. The handler does no DB writes, so this is purely shedding redundant log+emit work. test: integration over a real authenticated device socket — a burst of identical "ready" collapses to one log/emit, a "ready" after the window passes, and a status change is never deduped. Unique PORT (3984). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
101f086204 |
fix(#142): load-aware per-device reconnect throttle (the outage fix)
Gates genuine reconnects PER DEVICE before the heavy register work (DB writes + playlist build) runs, so a single flapping device can no longer saturate the event loop and take down the server. - Actuator is per-device, keyed on device_id (modeled on lastPlayLogAt). A device is flagged only when it exceeds reconnectBaseMax genuine reconnects per window. Same-socket playlist refreshes (isPlaylistRefresh) are exempt. - Load-awareness is BANDED (normal/elevated/critical from the step-2 lag signal), not a continuous controller. The band only MULTIPLIES an already-flagged device's backoff; global lag never gates a healthy device. - Hysteresis: escalate immediately while storming (tighten fast); decay one level per reconnectReleaseMs of calm (release slow). - HARD CEILING per device, independent of band and warm-up — a slow-ramp attacker can't train through it. - COLD START: for reconnectWarmupMs after boot, force the normal band and apply only the hard ceiling, so a full-fleet reconnect after a deploy doesn't throttle healthy screens. State is in-memory, resets on restart. - Observability: every throttle engagement logs device, band, observed vs allowed rate, and backoff. Throttled device gets device:throttled + a deferred disconnect. Tests (api.test.js style): - unit: healthy-never-throttled, storm-throttled-with-growing-backoff, band multiplies backoff, hard-ceiling-even-in-warmup, warm-up leniency, neighbor isolation, slow release. - integration GATE (the required one): full-fleet reconnect right after restart throttles NO healthy device; a single device storming IS throttled; a neighbor stays unaffected while another storms. - also fixes pre-existing test PORT collisions (my new integration files clashed with totp.test.js:3979 and totp-keyrotation.test.js:3980 -> moved to 3982/3983); full suite now green serially AND in parallel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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 (
|
||
|
|
1f2e923005
|
fix(#134): quiet false "reconnect" log + report HDMI output and UI render resolution (#136)
Two device-REPORTING fixes from the #134 investigation (the PiP rendering itself was #135). 1) "Device reconnects every ~45s" was a logging artifact, not instability. The player re-emits a full device:register on the SAME socket every ~45-60s (requestPlaylistRefresh) to pull a fresh playlist; the server logged "Device reconnected" for every register of a known device. The attached 4-day log showed 1415 "reconnected" vs 30 real socket connects and 0 heartbeat timeouts — the socket never dropped, so #134's "PiP lost between reconnects" was a misdiagnosis. Fix: only log a genuine reconnect (new socket); a same-socket re-register is a refresh (currentDeviceId === device_id) and stays quiet. The playlist still refreshes. 2) Device reported 720p while the monitor showed a 1080 signal. DeviceInfo reported getRealMetrics() — the UI RENDER SURFACE — but TV boxes render the UI at 720p and upscale to a 1080p HDMI signal. Now report BOTH: screen_width/height = the output mode (Display.Mode.physicalWidth/Height), render_width/height = the render surface (getRealMetrics). Two new nullable devices columns, stored on pairing INSERT + reconnect UPDATE, exposed via the device API, shown on the dashboard as "1920x1080 (UI 1280x720)" when they differ. Backward compatible (required + verified on emulator): a device that omits render_* — or sends no device_info at all — still registers, with render_* = null, on both the INSERT and UPDATE paths. New columns nullable; stores use `?? null` / `|| null`. All 167 server tests pass. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1c748b8d3b |
feat(preview): draft-aware device-free playlist preview via player reuse (#104)
Replaces the broken/fragmented preview with a single surface that renders a DRAFT playlist exactly as a device does, by reusing the player's renderer in a same-origin iframe. Fixes "not all items load" (one renderer, full type union) and inherits the player's YouTube correctness (YT.Player handshake). Server: - deviceSocket: extract assemblePayload() (zone-reset + canonical shape) from buildPlaylistPayload so the device path and preview can't drift. Pure refactor (all 149 tests green). - playlists: GET /:id/preview-payload (requirePlaylistRead, workspace-scoped). Draft-aware via buildSnapshotItems (live items, not published_snapshot); derivePreviewLayout() resolves layout from the playlist's own zone-bound items (0 zoned -> fullscreen; 1 -> use it; >1 -> dominant + ambiguous flag, never crashes). orientation validated/passthrough; wall_config/timezone null. Player (renderer UNTOUCHED): - ?preview=1&playlist=ID boot branch: fetch preview-payload (same-origin Bearer token) and call handlePlaylistUpdate(). Gated before the pairing/socket path so the unpaired auto-connect never fires. All socket emits already guarded. - Webpage widgets: always-visible honest note (no auto-detection — an XFO refusal is provably indistinguishable client-side from a working embed). Dashboard: - playlists: Preview button + player-iframe modal with landscape/portrait toggle. - widgets: same honest note on the existing widget preview modal (the surface the bug was reported on). - i18n x6 (en/es/fr/de/it/pt) + player i18n x5. Validated end-to-end (headless Chrome + CDP): preview boots, webpage note renders, 3-zone layout derives+renders, shape parity with device snapshot proven on real data, auth gate returns 401. The world-readable /uploads finding is tracked separately as #107 (not a #104 concern — same path the device uses). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2ccf3264a9 |
feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Each playlist item can carry schedule blocks (active days, start/end time-of-day, optional start/end dates). An item plays when the screen's local "now" matches at least one block; an item with no blocks always plays. #74 covers time-of-day/day-of-week windows including overnight wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is on-device, so dayparting and expiry work offline. - Shared evaluator contract: shared/schedule-vectors.json (39 vectors — DST US+AU, overnight-wrap anchoring, timezone correctness, date boundaries). Canonical JS evaluator in server/lib/schedule-eval.js; Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff test, Kotlin JUnit reads the shared JSON, new android-test CI job). - All three players (web, Android, Tizen) filter by schedule against their own clock, idle with a "Nothing scheduled" message + 30s re-check when everything is filtered, and fail open on any evaluator error. - Editor: per-item schedule modal + row badge in the playlist editor; client validation mirrors the server; editing marks the playlist draft. - Part B (behaviour change): device/group schedule overrides now evaluate in each device's effective timezone instead of server-local time. - Device detail shows the reported timezone + a clock-skew warning. - i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.* to avoid colliding with the device-schedule calendar's schedule.*). - CHANGELOG documents the feature, the Part B change, the fail-open guarantee, and the scheduled-single-video re-render tradeoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |