Commit graph

520 commits

Author SHA1 Message Date
screentinker c2115c88e3
fix(tizen): portrait/flipped VIDEO via AVPlay (hardware-plane rotation) — no more black screen (#170) (#174)
Bold field report: portrait video on Tizen = black screen. Root cause: our orientation
support rotates #stage with a CSS transform, but on Tizen the HTML5 <video> is composited
on a HARDWARE video plane that ignores CSS rotate — so portrait/flipped video blacks out
(images/widgets/text rotate fine). It's a Tizen platform limitation, not a regression.

Fix: route portrait / portrait-flipped VIDEO through Tizen AVPlay, whose setDisplayRotation
rotates the hardware plane itself. Scoped to portrait/flipped ONLY — landscape keeps the
proven HTML5 <video> path (double-buffer + group-sync drift), so the working case is untouched.

- index.html: <object id="avPlayer" type="application/avplayer"> hole-punch surface (hidden
  until a portrait video plays; off-hardware it's inert).
- player.js: setOrientation()/avAvailable(); renderVideoAv() (open/setDisplayRect/
  setDisplayRotation 90|270/setDisplayMethod LETTER_BOX/prepareAsync/play; onstreamcompleted
  -> loop|advance); avStop() torn down in clearStage() before every render; renderVideo()
  branches to AVPlay only for portrait/flipped when webapis.avplay exists.
- Graceful: any AVPlay error/absence -> honest note (portrait_video_unsupported, 5 locales),
  NEVER a silent black screen. currentVideoEl stays null for AV (portrait is solo-first;
  schedule engine still drives index/position).
- app.js: applyOrientation() now tells the player the orientation so renderVideo can choose
  the path.

Exit-signal marker slice unaffected (test green). Landscape + off-hardware paths unchanged.
NEEDS ON-DEVICE VALIDATION on a real Tizen TV (AVPlay can't be exercised off-hardware) —
signed test .wgt built for Bold.

Refs #170.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:26:27 -05:00
screentinker 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>
2026-07-13 11:26:04 -05:00
screentinker 2dc1d1279a
feat(seo): IndexNow + landing-page optimization (schema, FAQ, CWV, content) (#177)
* feat(seo): enable IndexNow (key file + submission script)

Instant re-crawl pings to Bing/Yandex/Seznam/Naver on content changes (Google ignores IndexNow
but uses the same sitemap). Hosts the ownership key at frontend/<key>.txt (served at
https://screentinker.com/<key>.txt) and adds scripts/indexnow-submit.sh which POSTs the sitemap
URLs to api.indexnow.org (DRY_RUN=1 to preview). Run after a content deploy / from CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(seo): landing-page optimization pass (schema fixes, FAQ, content depth, CWV)

From a 3-way SEO audit (technical / structured-data / content). Highest-value fixes:

Structured data (penalty risk + rich results):
- REMOVE the fabricated aggregateRating (4.8/50) from SoftwareApplication — no visible reviews
  on the page = a "spammy structured markup" risk. Replace loose Offers with a proper AggregateOffer
  + publisher + image/screenshot.
- Add a VISIBLE FAQ section (10 Q&As) so the FAQPage schema finally has on-page content (it shipped
  4 Q&As with no visible counterpart — a mismatch); expand the FAQPage JSON-LD to mirror all 10.
- Add a WebSite entity block; add YouTube to Organization sameAs + a description.
- Fix the guides/compare BreadcrumbList position-2 target (dead /#features -> /).

Content / keywords / IA:
- Hero + Features subtitle rewritten to surface "digital signage software" / "digital signage CMS"
  / "self-host" / "free" above the fold.
- New "How It Works" (3-step) and "Use Cases / Industries" (8 verticals) sections for snippet +
  long-tail intent. Platform tiles (Android TV / Fire TV / Raspberry Pi) now link to their guides.
- FAQ answers add keyword-rich internal links to the guides + compare pages.

Technical / Core Web Vitals:
- Lazy-load the YouTube iframe (loading=lazy + youtube-nocookie + explicit width/height) — the top
  LCP/TBT win on mobile.
- Title 84->~60 chars (keyword-front), meta description ~178->~156 + CTA.
- <div> footer -> <footer> landmark; favicon sizes (192+512).
- sitemap.xml: add <lastmod> to all 10 URLs.

All JSON-LD validated (4 blocks parse; single H1; no fabricated data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(seo): open-source/what-is pillars, Xibo+Anthias compares, integrations hub [#177]

Competitor-SEO gap-fill from the Yodeck/ScreenCloud/OptiSigns/Xibo teardowns.
11 new static pages + sitemap/landing/README wiring. All match the existing
seo-page.css template; BreadcrumbList on every page, FAQPage (visible-backed)
on guides + integration spokes. No fabricated ratings.

Pillar guides:
- guides/open-source-digital-signage.html  (head term "open source digital signage")
- guides/what-is-digital-signage.html       (TOFU definitional pillar + FAQ)

Comparisons (the open-source SERP Xibo/Anthias own):
- compare/xibo-alternative.html    (wedge: every ScreenTinker player free vs Xibo's paid Android/Tizen/webOS licences; no free plan)
- compare/anthias-alternative.html (wedge: fleet + video walls + multi-platform vs one-Pi-one-screen)

Integrations (OptiSigns' top tactic — one page per app):
- integrations/index.html hub
- google-slides / canva / power-bi  (honestly framed as the universal Webpage widget, with the X-Frame-Options / publish-vs-edit-URL caveat + Power BI public-data warning)
- youtube / rss / weather           (native widgets)

Wiring:
- sitemap.xml +11 URLs (lastmod 2026-07-13)
- landing.html Resources grid: 6 new cards (open-source, what-is, vs Xibo, vs Anthias, integrations hub)
- README.md: keyword-rich open-source/self-hosted intro + platform list + guide links (GitHub-SERP asset)
- docs/seo-directory-listings.md: off-repo G2/Capterra/AlternativeTo/awesome-selfhosted/fingoweb submission checklist + reusable kit

Validated: all JSON-LD parses, canonicals match paths, 0 broken internal links, sitemap well-formed (21 URLs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:22:37 -05:00
screentinker 12c0004245
test(ci): OS-assigned ephemeral ports for subprocess suites — kill the port-collision flake (#176)
The subprocess-booting test suites hand-picked fixed ports in a cramped ~3955-4021 range, and
156-schedule-read-path deviated to a RANDOM port (3900 + rand%90) that overlapped those fixed
ports. Under CI load two servers could race on the same port, surfacing as flaky "no such table:
devices" / "FOREIGN KEY constraint failed" (a server answering a request against a half-migrated
or wrong DB). It's environmental — the suites pass locally and in isolation.

Fix: a shared test/helpers/free-port.js (bind :0 on loopback, read the OS-assigned port, release)
called in before() so every suite gets a guaranteed-unique ephemeral port — concurrent suites can
no longer collide, and no one has to hand-assign ports.

- Codemod converted 30 suites: const PORT = <fixed|random> -> let PORT (+ BASE) assigned via
  `PORT = await freePort()` at the top of before().
- 3 hand-fixed (different structure): 148-eviction-storm (lowercase `base`), boot-health (no
  before() — allocates PORT + a throwaway SEED_PORT inside the test, replacing the hardcoded
  3894), totp-keyrotation (no before() — allocates at the test start before bootServer()).

No fixed 39xx/40xx ports remain. Full server suite 435/435; the 4 hand-touched suites pass in
isolation. Pure test-infra change — no app code touched.
2026-07-13 09:51:40 -05:00
ScreenTinker 40efaa1fed chore(release): v1.9.5
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-12 23:32:37 -05:00
ScreenTinker 29ea6f6e99 fix(release): bump-version.sh handles env-overridable android version (#168)
Since #168 android versionName/versionCode became env-overridable, the values in
build.gradle.kts live as fallback literals inside `?: "…"` at the end of each line, not
as `versionName = "X"` / `versionCode = N`. The old sed/grep found no versionCode digits,
so $((CODE + 1)) errored under set -e and the whole bump aborted after touching VERSION +
package.json. Retarget the trailing `?: "literal"`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 23:32:36 -05:00
screentinker 837f65e634
fix(content+android): rotation-aware media — portrait upright on dashboard AND player (#170) (#172)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* fix(content): rotation-aware media dimensions — portrait no longer stored landscape (#170)

Ingest recorded CODED width/height and ignored rotation, so a portrait phone video
(coded 1920x1080 + 90° Display-Matrix) or a portrait photo (EXIF orientation 6) was
stored LANDSCAPE. The player then rendered it wrong-aspect and letterboxed — the
"portrait content degraded + blue bar at the bottom" symptom in #170. The reporter's
workaround (pre-rotate + mark Landscape) is exactly what this bug forces.

- lib/media-orientation.js (new): pure, unit-tested display-dimension helpers = single
  source of truth for ingest AND the backfill. videoDisplayDims() reads the modern
  Display-Matrix side_data rotation (falls back to the legacy tags.rotate, sign-normalized);
  imageDisplayDims() honors EXIF orientation 5..8. Odd quarter-turns swap W/H.
- lib/content-ingest.js: use the helpers for stored dims; add sharp .rotate() so image
  THUMBNAILS are auto-oriented too (video thumbs were already auto-rotated by ffmpeg).
- scripts/backfill-rotation-dims.js (new): idempotent, dry-run-by-default maintenance to
  correct already-uploaded portrait media (re-probe -> fix dims -> regenerate image thumbs).
- test/media-orientation.test.js: 5 bites (tag + Display-Matrix, sign/normalize, EXIF 5..8,
  the blue-bar landscape->portrait case, null-safety).

Scopes #170 to its residual-on-1.9.4 issues; the 1.9.3 "never displays" slice was #162 +
the remote_url-null download fix, already shipped in 1.9.4. The slow low-res/orientation-
cycling first load is tracked separately in #170 pending repro data.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): honor EXIF orientation in ImageLoader so portrait photos render upright (#170)

Completes the rotation-aware media fix on the PLAYER side. The server ingest fix (this
branch) corrects stored dimensions + auto-orients the thumbnail, but the panel draws the
full-res original via BitmapFactory, which ignores EXIF — so a portrait photo (landscape
pixels tagged "rotate 90") still rendered sideways on the screen. QA root-cause pass on
#170 caught this gap: the Android player reads no stored dims and applied no EXIF.

ImageLoader now reads the EXIF orientation (from the file for cached content, from the byte
stream for remote_url images — ExifInterface(stream) is API 24+, minSdk is 24) and rotates/
flips the decoded bitmap via a Matrix (all 8 orientations). NORMAL/UNDEFINED is a no-op (no
extra allocation); a transformed copy recycles the source; OOM falls back to the source
rather than crashing. Videos were already correct (ExoPlayer honors the rotation matrix).

Verified: :app:compileDebugKotlin clean.

Refs #170. Rides with the server rotation-dims fix on this branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:05:11 -05:00
screentinker 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>
2026-07-12 21:23:25 -05:00
screentinker 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>
2026-07-12 20:48:47 -05:00
screentinker 501ffb11c1
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159.
2026-07-12 19:41:07 -05:00
screentinker 938a43a466
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* 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>
2026-07-11 14:24:31 -05:00
Fabian Mendoza 34f1cb9e7c
feat(dashboard): version indicator + GHCR update check (#165)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
* feat(dashboard): version indicator + GHCR update check with admin panel

- Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter)
- Extend /api/version with latest_version and update_available
- Add POST /api/admin/check-update (force GHCR poll)
- Add POST /api/admin/trigger-update (Docker compose or manual instructions)
- Sidebar footer: version label + amber badge when update available
- Admin > System: version comparison card with Check/Update buttons
- 14 new tests (10 unit + 4 integration), 68/68 passing

Closes #163

* fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout

Review follow-up on #165 (the two blockers):

- trigger-update runs `docker compose up -d` on the HOST via docker.sock
  (root-equivalent) but was behind requireAdmin, i.e. reachable by any
  workspace-level admin. On a multi-tenant host that's a customer, not the infra
  operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates
  it further). check-update stays requireAdmin — it's a read-only GHCR poll.

- ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default
  timeout, so a hung GHCR connection never settled — leaving `inFlight` set
  forever (the finally never ran), which wedged the background poller AND hung
  any awaited checkNow (/api/admin/check-update). Add a 10s AbortController
  timeout on both requests so the try/catch/finally always fire.

All 405 server tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ScreenTinker <hello@screentinker.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:40:06 -05:00
screentinker 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>
2026-07-10 22:38:58 -05:00
Fabian Mendoza c63af0e6bd
fix(player): send device_id/token on reconnect before pairing (#164)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
When the web player socket reconnects before the device is paired,
register() omitted device_id and device_token (gated behind config.paired).
This caused the server's fingerprint reclaim guard to treat the reconnect
as a fresh anonymous registration with a colliding fingerprint, firing
device:auth-error.

Now device_id and device_token are sent whenever they exist, regardless
of paired status. The pairing code is also reused across reconnects
instead of generating a new random code each time.

Closes #163

Co-authored-by: BlazzzPlay <fabianma7@gmail.com>
2026-07-10 13:04:18 -05:00
ScreenTinker 570f7919e0 test(tizen): realign wgt exit-signal harness slice after #162 stage-owner edit
The v4-exit-signal-phase3 tests eval a hardcoded LINE RANGE out of tizen/js/app.js
(harness(TIZEN, 663, 697)). The #162 stage-owner fix inserted ~19 lines above that
block, so the slice no longer captured the crash/pagehide handlers and the three B/wgt
tests failed. Re-point the range to 682-716 (block content unchanged, verified identical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:12:30 -05:00
ScreenTinker f7e1b4dc69 chore(release): v1.9.4 2026-07-10 11:57:43 -05:00
ScreenTinker 5f12315e39 fix(tizen): player wedge on shared #stage — same class as #162
PlaylistPlayer and ZoneRenderer share one #stage node, but every playlist-update
unconditionally blanked the OTHER renderer (zoneRenderer.clear() in the single-zone
branch, player.stop() in the layout branch) and then hit that renderer's unchanged-
signature `return` — leaving the stage BLANK. Same class as the Android #162 wedge (a
stale "still on screen" belief trusted while nothing is actually rendered):
- fires on the routine ~60s heartbeat re-register (server re-pushes the same playlist),
- permanent for a single looping item (no advance timer to self-heal),
- also stranded the stage on suspend -> resume and cold-start cached-playlist restore.

Fix: track which renderer owns the shared stage (stageOwner) and only blank the other
one when actually switching modes, invalidating the incoming renderer's signature so it
repaints on the switch; never blank on a same-mode unchanged update. Added
ZoneRenderer.invalidate() to mirror PlaylistPlayer.invalidate(). Verified with a
faithful state-machine simulation: old code blanks 8x across a realistic
pair/heartbeat/switch/suspend sequence, fixed code 0x.

The web player (server/player/index.html) was reviewed and is NOT vulnerable — its
unchanged-guard already verifies real DOM surface health (needsReattach, the #146 fix)
and its JS state resets on reload, so there is no analogous wedge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:52:50 -05:00
ScreenTinker f60f677cf0 fix(android): player provisioning + playback robustness
Client-side fixes to the Android signage player, all validated end-to-end on a
Pixel-10 emulator (Android 16) against the alpha server.

- content download: a local item with "remote_url": null was mis-tagged as a
  remote stream (org.json optString returns the STRING "null" for a JSON null),
  so it was ack'd "ready" and NEVER downloaded — stranding the screen on
  "waiting for content" and only ever playing 1 of N files. Guard with isNull().
- playback (#162): PlaylistController trusted isRunning+currentIndex as "already
  playing" and never re-called playItem, permanently stranding a panel on
  "waiting for content" after a restart/OTA/content-not-ready-at-first-start.
  Guards now require hasContentOnScreen (a genuine render) before short-circuiting.
- provisioning: revert to the URL-entry screen if a connect attempt hangs >60s
  (wrong/unreachable URL) instead of an endless "Connecting to server…".
- re-pair: a server rejection (device:unpaired / auth-error) left the device
  connected-but-unregistered with no pairing code (stuck); a naive re-register
  then stormed the #150 reclaim guard ~20x/s. Now: re-register once, debounced +
  backed off; honor the reclaim-settle window with a stable "re-pairing available
  in Xs" countdown; show the code only once the server accepts it (isPairingCodeLive).
- status: a fully-online device could sit on a stale "Connecting to server…" when
  MainActivity was relaunched (CLEAR_TASK) after the service already registered —
  it now pulls a fresh playlist on bind so the real state renders.
- setup: add a Default Launcher (HOME role) step so a kiosk can be set as the
  default launcher without adb (prevents ~45s activity-recreate churn).
- debug: new DebugLog.v() streams the deep download/playback trace only while
  live dashboard debug is enabled; silent in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:34:47 -05:00
ScreenTinker b72e964433 feat(dashboard): surface per-device settings PIN + backfill existing fleet (#152)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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>
2026-07-09 19:55:28 -05:00
BlazzzPlay 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).
2026-07-09 20:09:58 -04:00
ScreenTinker e0b45bf907 fix(playlists): return item schedules from GET /:id so the editor shows them (#156)
GET /:id built the items array but never called schedulesForItem, so the playlist
editor rendered "always plays" for items that have a live schedule. Because the
editor re-PUTs whatever it loaded and PUT .../schedules is a wholesale
DELETE+INSERT, an unchanged save on a mis-loaded item silently wiped the real
schedule. Mirror GET /:id/items:351 so the read path returns the blocks the
editor and player already agree on.

Adds render / round-trip / wipe-trap regression tests (subprocess HTTP harness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:52:27 -05:00
BlazzzPlay 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
2026-07-09 19:05:24 -04:00
Fabian Mendoza 90b8cbb1e6
fix(preview): server-side preview sessions to bypass CSP (#151)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* fix(preview): replace srcdoc with server-side preview sessions to bypass CSP

Widget previews (clock, weather, etc.) were rendered via iframe.srcdoc,
which inherits the dashboard CSP script-src 'self'. This blocked the inline
scripts widgets need (setInterval for clock, fetch for weather), causing
previews to show blank/static content.

Replace srcdoc with ephemeral server-side preview sessions:
- POST /api/widgets/preview-session — stores rendered HTML (Map, 5min TTL)
- GET  /api/widgets/preview-session/:id — serves the HTML via iframe src,
  bypassing CSP like the device render endpoint already does

The old /api/widgets/preview endpoint is unchanged for backward compat.

* fix(preview): add rate limiter for /preview-session route

---------

Co-authored-by: BlazzzPlay <fabianma7@gmail.com>
2026-07-09 15:39:07 -05:00
BlazzzPlay c7f1eed63f feat(android): PIN gate for hidden settings menu 2026-07-09 10:50:34 -04:00
BlazzzPlay 6ca2782ec1 fix(android): remove orphaned duplicate showExitDialog() block 2026-07-09 10:42:54 -04:00
ScreenTinker 147ab6d3c8 fix(ota): treat legacy -patchN as a released version so the old fleet is offered updates
The -patchN scheme (e.g. 1.9.2-patch3) parses as a semver prerelease, so decide()'s
superseded-prerelease guard refused to offer a newer stable core (1.9.3) to the existing fleet —
stranding every 1.9.2-patchN device on OTA (Force Update didn't help; the re-check re-returned
superseded-prerelease). isReleased() now counts -patchN as a shipped release, so those devices get
offered 1.9.3 via normal OTA, while GENUINE prereleases (-beta/-rc/-alpha) keep prerelease semantics
and newer cores are never downgraded. 6 new tests + 14 existing OTA tests green (388/388 suite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:21:26 -05:00
ScreenTinker 09e11397b4 test: widen CI-fragile event-loop-gap timing bars (flaky prune/storm asserts)
CI flaked on the 300k-row prune non-blocking assert: a healthy chunked prune hit a 417ms max
event-loop gap on a shared runner, over the strict 250ms bar (the same test passed on the prior
commit; the release bump changed no logic). These probes exist to catch a MULTI-SECOND freeze (the
pre-fix whole-table sort froze 40-48s) — not to enforce a sub-300ms latency SLA — so a strict bar is
fragile under runner contention/GC. Widen both to <1500ms: still << "seconds" (catches any real
regression) but robust on CI. No production code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:31:27 -05:00
ScreenTinker dfd954d2ad chore(release): v1.9.3 2026-07-08 17:23:08 -05:00
ScreenTinker 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>
2026-07-08 15:32:55 -05:00
ScreenTinker 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>
2026-07-08 15:32:40 -05:00
ScreenTinker 2772d1fc4d fix(dashboard): liveness badge filter regression + list-view legibility
Two follow-ups from the alpha diagnosis:
- FIX A (regression): filterDevices() compared badge TEXT to the option values 'online'/'offline', but
  the badge text is now "Healthy"/"Reconnecting"/"Offline" — so selecting a status filter matched
  nothing and emptied the dashboard. Now compares the liveness STATE via a data-liveness attribute, and
  the filter is upgraded to All / Healthy / Reconnecting / Offline (an admin can filter TO reconnecting
  devices — the point of the Degraded distinction).
- FIX B (legibility): the list rendered liveness as a status-dot where healthy=green/offline=red were
  visually identical to the old indicator, so it didn't read as new. The list now renders the same
  device-status-badge PILL as device-detail (3 distinct colors; amber Reconnecting visible on the list),
  scoped with an is-liveness modifier so video-wall cards keep their dark "NxN wall" pill.

Frontend-only. 14/14 filter+render tests; full ES-module parse clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:56:18 -05:00
ScreenTinker 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>
2026-07-08 13:22:15 -05:00
ScreenTinker a0b47000f3 feat(csp): allow Cloudflare Web Analytics beacon to load AND report
The dashboard CSP (script-src 'self') blocked Cloudflare's Web Analytics beacon. Add the two exact
entries the beacon needs (both required — script-only loads but silently can't report):
- script-src:  https://static.cloudflareinsights.com  (beacon script loads)
- connect-src: https://cloudflareinsights.com          (beacon POSTs analytics back)
Exact domains, no wildcards. connect-src already had 'wss:'/'ws:' (socket.io) + 'https:' — those stay,
so the dashboard socket is unaffected; the explicit CF domain documents intent and survives any future
tightening of the broad 'https:'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:03:56 -05:00
ScreenTinker c5ddb82cba fix(dashboard): device-detail.js parse + runtime errors that killed the whole view
The #150 re-adopt commit (74e7062) left device-detail.js unparseable and, once parsed,
unexecutable — so the entire device-detail view's JS was dead (settings, #150 re-adopt UI, delete):
- SyntaxError at 768: `await api.getContent()` at the top level of the non-async setupActions()
  ("Unexpected reserved word") -> the whole module fails to parse. Fixed with the .then() pattern
  already used by the sibling playlist picker, keeping setupActions synchronous so every listener
  below it (save, #150 re-adopt, delete) still registers immediately (making it async would defer
  them behind the fetch).
- Stray `async` orphaned on its own line (was line 648) before showReAdoptModal's doc comment:
  parses, but executes as the bare identifier statement `async;` -> ReferenceError at module load,
  which would keep the view dead even after the parse fix. Removed it.
Also add <meta name="mobile-web-app-capable"> beside the apple- one (clears the deprecation warning).
Full frontend ES-module parse-scan clean; both bugs were confined to this file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:56:39 -05:00
ScreenTinker 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>
2026-07-08 11:18:55 -05:00
ScreenTinker 80d6242806 feat(player): v4 liveness contract — throttle-aware watchdog + browser triggers + identity
/player v4 — client-only (served web player). Brings the browser player onto the locked v4
liveness contract, IDENTICAL on the wire to the now-v4 APK and .wgt:
- v4 liveness watchdog: consume device:heartbeat-ack, lastServerMessageAt refreshes on ANY inbound
  (onAny + engine ping) while ARMING gates on the ack (degrade-safe); 45s±10s jittered threshold;
  backoff 1s/30s/±0.2 on the socket.io Manager; NO status/health poll; teardown-before-reopen (#148).
- Browser-specific half-open triggers (the /player-unique part): Page Visibility, sleep/resume
  (pageshow/bfcache), network change (online) — all drive the SAME #148 teardown-first reconnect.
- THROTTLE-AWARE: silence computed by timestamp (not timer-fire-count); watchdog does NOT act while
  the tab is HIDDEN (throttled-timer gap is expected); on becoming visible, verifyLivenessSoon()
  resets the grace and reconnects ONLY if genuinely dead — never spuriously tears down a live socket.
- v4 client identity block on register (client_type=player / client_version / platform / v4).
Verified: arm-after-ack both directions, v4 values MATCH .wgt exactly, browser-trigger no-duplicate-
socket, throttle-aware reproduce-then-prove (real extracted checkLiveness/verifyLivenessSoon),
foreground-recovery one-socket. Depends on the server device:heartbeat-ack (core pass) — degrade-safe
until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:34:05 -05:00
ScreenTinker bcbb3752c6 feat(tizen): .wgt v4 delta — arm-after-ack, threshold/backoff params, identity block
Tizen .wgt v4-conformance delta — client-only, targeted (the lifecycle hardening was
already merged; this only closes the v4 gaps the .wgt predated):
- Arm-after-ack (the behavioral change): the watchdog arms ONLY after a
  device:heartbeat-ack, not on any inbound/engine ping — so an ack-less/old server
  never arms it (degrade-safe). markAlive still refreshes lastServerMessageAt on any
  inbound for the silence check; arming gates on the ack.
- Threshold: fixed 35s -> v4 canonical 45s ± up to 10s jitter (re-jittered per connect).
- Backoff params: 1s start / 30s cap / ±20% jitter (was 2s/10s/±50%), exp-double kept.
- v4 client identity block on register (client_type=wgt / client_version / platform /
  contract_version=v4), canonical snake_case matching the APK.
- No-poll confirmed. Preserved untouched: keep-awake re-assert, resume handler, #148
  teardown-before-reopen, 4th-beat refresh, unpaired 3s backoff.
Verified: arm-after-ack both directions (engine-ping-no-ack -> NOT armed; ack -> arms +
catches half-open), v4 runtime values, #148 one-socket-one-register, server suite green.
Depends on the server device:heartbeat-ack (core pass) — degrade-safe until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:58:42 -05:00
ScreenTinker 57cdaf7e4e feat(apk): v4 liveness contract + caching-cluster fix + reconnect-safe downloads
APK v4 — client-only (Android player). Brings the reference client up to the locked
v4 liveness contract and fixes the "stuck downloading / offline in CMS" caching bug:
- v4 liveness watchdog (LivenessWatchdog): half-open detection via server-silence,
  arm-ONLY-after device:heartbeat-ack (degrade-safe), 45s±10s jittered threshold,
  exp backoff 1/2/4/8/16→30s ±20%, no-poll; reconnect delegates to the #148
  ConnectionGuard (teardown-before-reopen, single socket).
- Caching two-root fix: callTimeout + .part+Content-Length integrity + atomic swap
  (CacheValidation), onPlayerError advance, re-ack cached content + reconnect re-ack.
- Screen resilience (PlaylistSelection): a pending/failed download never blanks the
  screen — keep-current, swap only fully-valid content.
- Reconnect-safe background downloads (DownloadCoordinator): single-flight per
  contentId + bounded pool + failure backoff + cancellation; a reconnect mid-fetch
  can't orphan/duplicate/storm. Refuse 206 partials.
- v4 client identity block on register (client_type/version/platform/contract_version).
Tests: 52 JVM unit tests (watchdog, cache validation, reproduce-then-prove download
stall/truncation/reconnect-mid-download, screen selection, assembly soak).
Depends on the server device:heartbeat-ack (core pass) — degrade-safe until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:57:53 -05:00
ScreenTinker 5c3d1a18c5 Merge Tizen .wgt lifecycle batch (client-only) into main for patch4
30+ fix batch + verification + regression pass, all client-only (tizen/js/app.js + player.js):
- Watchdog (config-proof: pingInterval-derived window + arm-after-signal; monotonic clock),
  keep-awake re-assert + suspend/resume handler, #148-safe teardown-before-reopen throughout.
- Timer/teardown hygiene, single-item dead-screen self-heal, reconnect jitter, offline snapshot,
  input hardening, keep-awake observability, unpaired backoff.
No server-side change. Verified: full suite green, lifecycle soak (one socket / no dup register /
flat listeners), rotation path single-apply-site consistent. NOT a fix for the rotation-on-reload
report (separate). config.xml NOT yet bumped; no build/tag.
2026-07-07 22:37:06 -05:00
ScreenTinker 646eab743a fix(tizen): P0 audit fix pass — watchdog config-proofing, teardown hygiene, dead-screen self-heal, offline snapshot
Client-only, no server change. Implemented in verified clusters:
- H1 (config-proof, no heartbeat-ack): derive the liveness window from the server-negotiated
  pingInterval (version-robust read) + arm the watchdog only after a real inbound signal, so it
  degrades safe against any server and a raised PING_INTERVAL can't false-fire it into a storm.
- A5: monotonic clock (performance.now) for watchdog/resume deltas — NTP/RTC jumps can't false-fire
  or blind the watchdog.
- H4 (leak was verified ABSENT): timer/teardown hygiene — tracked register-retry + teardownSession()
  on reset/BACK (stop heartbeat/stream/player-loop/pending-register); all start*() are stop-first.
- A1: single-item playlist retries a broken item (was a permanent black screen while heartbeat green).
- A6: reconnect randomizationFactor 0.5 (no fleet thundering-herd) + timeout 10s->20s (parity).
- A2 (minimal): cache last renderable playlist-update to localStorage, replay on cold-start/offline;
  cleared on unpair/reset/auth-error.
- B3: input hardening (non-array assignments/zones guarded, duration_sec numeric-coerced).
- A3: log keep-awake API availability so Bold can VERIFY the flap fix on real hardware.
#148 double-connect discipline re-proven after socket-touching changes.
2026-07-07 21:58:58 -05:00
BlazzzPlay 69be6e804e feat(android): hidden settings menu with multi-tap BACK/ESC detection
Add an in-app settings menu reachable via 2× BACK (or ESC) taps,
with a 1.8s window — Android TV and touch devices.

- 2 taps: settings dialog (change server, re-pair, permissions, exit)
- 3 taps: exit dialog directly (skip menu)
- Auto-banner after 10+ consecutive connection failures

Settings options:
- Change server URL (pre-fills ProvisioningActivity)
- Reconfigure device (clear credentials → re-pair)
- Permissions (Accessibility + Notifications status → system settings)
- Device info (ID, APK version, connection status)
- Exit app (finishAffinity)

Also adds EXTRA_SERVER_URL to ProvisioningActivity and a
consecutiveFailures counter to WebSocketService.
2026-07-07 16:57:49 -04:00
ScreenTinker dcd3a05a7e feat(tizen): harden FIX B with an application-level liveness watchdog
Replace the resume-only hide-duration heuristic as the AUTHORITATIVE half-open detector with a
real server-silence watchdog, so the .wgt self-heals a dead-but-connected socket from ANY cause
(network drop, NAT idle timeout, transport death while foregrounded), not just resume.

- Central receive-path liveness: markAlive() refreshes lastServerMsgAt on EVERY inbound server
  message — app events via socket.onAny, and the server's ~15s engine ping via socket.io 'ping'
  (both client-only signals the server already sends; no server change; heartbeats get no ack).
- Watchdog (10s cadence): if socket.connected && authenticated && silent > 35s (2+ missed pings,
  under engine.io's own ~45s close), treat as half-open and reconnect via the teardown-first
  connect() -> exactly one socket, #118 re-registers once.
- #148 discipline: fires ONLY while socket.connected===true (the state socket.io can't see), so
  it never races socket.io's own down-socket auto-reconnect; connect() resets liveness so the
  watchdog and the resume fast-path can't double-fire. Resume path kept as the fast suspend path.
- Timers cleared on exit.

Client-only; keep-awake+lifecycle remain the leading flap candidate, NOT a confirmed cause.
2026-07-07 14:57:50 -05:00
ScreenTinker 78c71e00ab feat(tizen): .wgt lifecycle parity — keep-awake re-assert + suspend/resume handler + fixes
Client-only. Brings the standalone Tizen .wgt player toward APK//player parity:
- A: re-assert keepAwake() on a 30s interval (power lock / screensaver-off can be released
  when the TV backgrounds the app); cleared on exit.
- B: visibilitychange/resume handler. On resume re-asserts keep-awake and, ONLY for the
  half-open case socket.io cannot detect (connected===true after a suspend-length hide),
  owns a clean teardown-before-reopen via connect() (exactly one socket, #118 re-registers
  once). Defers to socket.io's auto-reconnect when the socket is already disconnected — the
  two are mutually-exclusive states so no manual reconnect races socket.io. No manual re-register.
- C: 4th-beat now re-emits device:register (real fallback playlist refresh) instead of a
  duplicate device:heartbeat.
- D: APP_VERSION_FALLBACK 1.9.1 -> 1.9.2 (repo hygiene; build-wgt.sh stamps at build).
- F: device:unpaired now backs off 3s before re-registering (symmetric with auth-error),
  so MDM re-pair churn can't tight-loop.
Keep-awake (A+B) is the LEADING flap candidate, NOT a confirmed cause. Offline caching (E)
deliberately excluded. No server changes; no bump/tag/build.
2026-07-07 14:39:16 -05:00
ScreenTinker 01f669dec9 Merge #150: preserve per-device settings across delete+re-pair + re-adopt UI
Backend: fingerprint-keyed device_settings table (survives the delete cascade), snapshot-on-
delete, auto-restore on fingerprint-match re-pair, operator re-adopt API, tenant purge.
Frontend: 'Restore from removed device' picker in device detail (blocked warning, confirm,
refresh). Deferred: wall-membership restore (TODO).
2026-07-07 13:12:34 -05:00
ScreenTinker 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.
2026-07-07 12:52:42 -05:00
ScreenTinker 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.
2026-07-07 12:40:47 -05:00
ScreenTinker be01674d35 chore(release): v1.9.2-patch3
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-06 23:51:44 -05:00
ScreenTinker 099320af29 fix(db): WAL checkpointer worker-death handling (respawn + inline-autocheckpoint fallback)
Close the disk-fill trap: with wal_autocheckpoint=0 a dead worker means nothing checkpoints.
Controller now respawns an unexpectedly-dead worker (bounded: RespawnMax/RespawnWindowMs +
backoff); on exhaustion it re-arms a conservative inline autocheckpoint (FallbackPages) on the
main connection + reclaims the backlog, logging loudly. Clean stopWalCheckpointer() teardown is
distinguished via a 'stopping' flag so SIGTERM never triggers respawn. Env-gated worker
fault-injection (WAL_CKPT_FAIL_START) for tests. Local only — no bump/tag.
2026-07-06 23:50:18 -05:00
ScreenTinker de7bd18bf3 fix(db): off-main-thread WAL checkpointer (worker) to kill the ~60s p99 checkpoint spike
Disable wal_autocheckpoint on the main connection; run PASSIVE checkpoints from a
worker_threads worker with its OWN better-sqlite3 handle, escalating to TRUNCATE on a
size high-water or PASSIVE-starvation. Removes the synchronous fsync-heavy checkpoint
from the event loop. Config: walCheckpointIntervalMs/HighWaterMB/StarvationRuns.
Local only — no bump/tag.
2026-07-06 23:50:18 -05:00
ScreenTinker 1a5c468537 fix(#148) android root cause: single-socket-per-device invariant (no duplicate connections)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
The player opened duplicate/rapid WebSocket connections for the same device_id: connect() was
unconditional (disconnect + forceNew socket) and reachable from every lifecycle entry point
(boot, service start, MainActivity/ProvisioningActivity bind, foreground re-bind, START_STICKY).
A ROM that re-binds on foreground (MAXHUB PROC_STATE_TOP, isBindService:true) therefore
re-invoked connect() repeatedly -> a burst of sockets, each evicted by the next (the 8-in-9s
storm). Fire TV never re-binds like that, so it never reproduced.

- ConnectionGuard (new, pure/testable — service is the shell, per the OtaThrottle pattern):
  shouldOpenNewSocket(hasSocket, sameUrl, socketActive) — reuse a live/self-healing socket to
  the same url; open a new one only when none is usable.
- WebSocketService: connect() is now idempotent (@Synchronized + ConnectionGuard) — every entry
  point reuses the one socket, never opens a duplicate; body split into openSocket(). socketActive
  / currentUrl track the single socket.
- Single owner: onStartCommand now calls connect() so the SERVICE owns the one connection
  (idempotent across START_STICKY restarts), not whichever activity binds.
- Reconnect discipline: on io server/client disconnect (which Socket.IO does NOT auto-reconnect)
  mark the socket inert and schedule exactly ONE backed-off re-open — never a blind re-open loop;
  a transport drop keeps socketActive=true so Socket.IO's own reconnect is reused.

Test: ConnectionGuardTest (5, incl. 8-rapid-binds-all-reuse). :app:testDebugUnitTest green
(ConnectionGuard 5, OtaThrottle 7, ScheduleEval 1). NOT bumped/signed/released — Dan builds+signs
with the BMG keystore; 1.9.2-patch2 (server net) covers un-updated devices.
2026-07-02 19:29:50 -05:00