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).
This commit is contained in:
BlazzzPlay 2026-07-09 20:09:58 -04:00
commit d474122334
53 changed files with 3195 additions and 150 deletions

View file

@ -1 +1 @@
1.9.2-patch3
1.9.3

View file

@ -11,8 +11,8 @@ android {
applicationId = "com.remotedisplay.player"
minSdk = 24
targetSdk = 34
versionCode = 41
versionName = "1.9.2-patch3"
versionCode = 43
versionName = "1.9.3"
}
signingConfigs {
@ -46,6 +46,13 @@ android {
kotlinOptions {
jvmTarget = "17"
}
testOptions {
// Let JVM unit tests exercise Android-dependent code paths (e.g. ContentCache's real
// download logic, which logs via android.util.Log) without Robolectric — stubbed Android
// APIs return defaults instead of throwing "not mocked".
unitTests.isReturnDefaultValues = true
}
}
dependencies {

View file

@ -46,6 +46,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var config: ServerConfig
private lateinit var contentCache: ContentCache
private lateinit var downloadCoordinator: com.remotedisplay.player.data.DownloadCoordinator
private lateinit var screenshotCapture: ScreenshotCapture
private lateinit var touchInjector: TouchInjector
@ -138,6 +139,13 @@ class MainActivity : AppCompatActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
contentCache = ContentCache(this)
// Coordinated background downloads (single-flight + bounded pool + backoff) — reconnect-safe.
downloadCoordinator = com.remotedisplay.player.data.DownloadCoordinator(
cache = contentCache,
serverUrl = { config.serverUrl },
socketAlive = { wsService?.isConnected() == true },
onAck = { cid, status -> ackContentOnce(cid, status) }
)
screenshotCapture = ScreenshotCapture()
touchInjector = TouchInjector()
@ -185,8 +193,17 @@ class MainActivity : AppCompatActivity() {
// #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen)
onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) },
onRequestRefresh = { wsService?.requestPlaylistRefresh() },
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) }
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) },
// Screen-resilience: the defined "waiting for content" state — ONLY on a fresh device
// with nothing to show yet (never while content is on screen; that path keeps current).
onWaitingForContent = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) }
)
// Screen-resilience: an item is playable only when its content is actually available —
// a widget, a remote stream, or a fully-downloaded local file. A not-yet/failed download is
// skipped (kept in the background) instead of blanking the screen on a loading state.
playlistController.setContentReadyCheck { item ->
item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId)
}
// Setup media player
mediaPlayer = MediaPlayerManager(
@ -493,31 +510,23 @@ class MainActivity : AppCompatActivity() {
// Skip remote URL content - it streams directly
if (!remoteUrl.isNullOrEmpty()) {
wsService?.sendContentAck(contentId, "ready")
ackContentOnce(contentId, "ready")
continue
}
if (!contentCache.isContentCached(contentId)) {
Log.i("MainActivity", "Downloading content: $filename")
var downloaded = false
for (attempt in 1..3) {
val file = contentCache.downloadContent(config.serverUrl, contentId, filename)
if (file != null) {
wsService?.sendContentAck(contentId, "ready")
downloaded = true
break
}
Log.w("MainActivity", "Download attempt $attempt failed for $filename")
if (attempt < 3) Thread.sleep(2000L * attempt)
}
if (!downloaded) wsService?.sendContentAck(contentId, "failed")
}
// Background download is now COORDINATED: single-flight per contentId + a bounded
// pool + failure backoff. So a watchdog/ConnectionGuard reconnect mid-fetch can't
// spawn a duplicate racing the .part, orphan/pile up threads, or storm a failing
// URL. ensure() is non-blocking and idempotent: it re-acks cached content (SEED-A),
// defers when the socket is down (watchdog owns recovery), respects backoff, and
// downloads at most once. It acks ready/failed itself (deduped via onAck).
downloadCoordinator.ensure(contentId, filename)
}
// Start or resume playback after downloads complete — but ONLY in
// single-zone/fullscreen mode. In multi-zone, ZoneManager drives each
// zone; restarting the fullscreen controller here made it keep playing
// items behind the zones (wasted work + phantom audio for videos).
// Start/resume playback immediately — do NOT wait on downloads (they're async now).
// Screen-resilience plays whatever is cached and skips not-yet-ready items; the 3s
// recheck swaps new content in once its download completes. Single-zone only; in
// multi-zone, ZoneManager drives each zone.
handler.post {
if (zoneManager?.hasZones() != true) playlistController.startIfNeeded()
}
@ -529,6 +538,7 @@ class MainActivity : AppCompatActivity() {
}
wsService?.onContentDelete = { contentId ->
downloadCoordinator.forget(contentId) // drop in-flight/backoff state so a re-add re-downloads
contentCache.deleteContent(contentId)
playlistController.removeContent(contentId)
// Update cached playlist to reflect deletion
@ -655,6 +665,9 @@ class MainActivity : AppCompatActivity() {
wsService?.onRegistered = { _ ->
hideStatus()
// Root-2 (SEED-B): a disconnect may have dropped in-flight content-acks. Clear the
// de-dup set so the next playlist-update re-acks all content and the CMS re-syncs.
ackedContent.clear()
}
wsService?.onUnpaired = {
@ -669,6 +682,20 @@ class MainActivity : AppCompatActivity() {
}
}
// Root-2 content-ack de-dup. Re-acking content state (SEED-A) fixes the CMS "stuck downloading"
// label, but we must not re-ack the same (content,status) every 60s playlist refresh. This set
// is cleared on each (re)registration (see onRegistered) so a reconnect re-acks everything the
// server may have missed while we were disconnected (SEED-B), but is quiet within a session.
private val ackedContent = java.util.Collections.synchronizedSet(HashSet<String>())
private fun ackContentOnce(contentId: String, status: String) {
if (ackedContent.add("$contentId:$status")) {
// a status change for this content supersedes the opposite one
ackedContent.remove("$contentId:${if (status == "ready") "failed" else "ready"}")
wsService?.sendContentAck(contentId, status)
}
}
private fun playItem(item: PlaylistItem) {
hideStatus()
com.remotedisplay.player.util.DebugLog.i("Player", "playItem: ${item.filename} mime=${item.mimeType} widget=${item.widgetId ?: "-"} zone=fullscreen")
@ -704,22 +731,17 @@ class MainActivity : AppCompatActivity() {
return
}
// Local content - download if not cached
// Local content - play from cache. Screen-resilience: the controller only advances to
// items whose content is READY, so reaching here uncached is a rare race (e.g. the file
// was evicted between selection and play). NEVER blank or show a "Downloading…" screen —
// keep whatever is on screen and move on; the background download loop (onPlaylistUpdate)
// fetches it and it plays once fully + validly downloaded. Content update is a BACKGROUND
// operation; we only ever SWAP to fully-downloaded content.
val file = contentCache.getCachedFile(item.contentId)
if (file == null) {
Log.w("MainActivity", "Content not cached: ${item.contentId}, downloading...")
showStatus("Downloading ${item.filename}...")
thread {
val downloaded = contentCache.downloadContent(config.serverUrl, item.contentId, item.filename)
handler.post {
if (downloaded != null) {
playFile(item, downloaded)
} else {
showStatus("Download failed: ${item.filename}")
handler.postDelayed({ playlistController.next() }, 3000)
}
}
}
Log.i("MainActivity", "Content not ready at play time (${item.filename}) — keeping screen, advancing (bg download continues)")
downloadCoordinator.ensure(item.contentId, item.filename) // ensure it's being fetched (single-flight)
handler.post { playlistController.next() }
return
}
@ -1025,6 +1047,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
remoteStreaming = false
if (::downloadCoordinator.isInitialized) downloadCoordinator.shutdown() // cancel in-flight downloads (no orphan/leak)
zoneManager?.cleanup()
if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView
if (::mediaPlayer.isInitialized) {

View file

@ -18,6 +18,23 @@ class RemoteDisplayApp : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
installCrashExitSignal()
}
// Exit-signal contract v1 — 'crashed'. A global uncaught-exception handler fires a BEST-EFFORT
// blocking last-gasp to the server, then delegates to the previous default handler so the crash
// still propagates and the process dies normally. Runs on the crashing thread (already dying), so
// the short blocking POST is acceptable. BEST-EFFORT: a native/OOM kill runs no JVM handler ->
// nothing is sent -> the server infers 'silent'. Honesty: only ever emits 'crashed' here.
private fun installCrashExitSignal() {
val prev = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val detail = (throwable.javaClass.simpleName + ": " + (throwable.message ?: "")).trim()
com.remotedisplay.player.service.ExitSignal.send(this, "crashed", detail)
} catch (t: Throwable) { /* never mask the original crash */ }
prev?.uncaughtException(thread, throwable) // chain -> normal crash reporting + process death
}
}
private fun createNotificationChannel() {

View file

@ -0,0 +1,20 @@
package com.remotedisplay.player.data
/**
* Root-2 caching fix the pure, unit-testable "is this download complete?" rule (no Android deps,
* same pattern as ConnectionGuard / OtaThrottle). [ContentCache] is the imperative shell (OkHttp +
* filesystem); this owns the integrity decision so a truncated/partial body is never promoted to
* the cache dir and later served as if it were a whole file (which previously wedged playback on a
* corrupt asset with no error path).
*/
object CacheValidation {
/**
* Complete iff we wrote at least one byte AND when the server declared a Content-Length
* ([expectedBytes] > 0) we wrote exactly that many. A truncated body
* (bytesWritten < expectedBytes) is INCOMPLETE and must be discarded + re-fetched. When the
* length is unknown (chunked / -1), fall back to ">0 bytes" (an interrupted copy throws and is
* discarded upstream, so a silent zero-length truncation is the only residual gap).
*/
fun isComplete(bytesWritten: Long, expectedBytes: Long): Boolean =
bytesWritten > 0 && (expectedBytes <= 0 || bytesWritten == expectedBytes)
}

View file

@ -8,16 +8,31 @@ import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
class ContentCache(private val context: Context) {
private val cacheDir = File(context.filesDir, "content_cache").also { it.mkdirs() }
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.MINUTES)
.build()
/**
* Root-2 caching fixes vs the "stuck downloading / frozen" bug:
* - a hard OVERALL [callTimeout] so a slow-drip/stalled download on a HEALTHY socket can't hang
* forever (the old client only had a per-read timeout, which a trickle never trips),
* - download to a `.part` temp + integrity-check (Content-Length) via [CacheValidation] + atomic
* rename, so a truncated/interrupted body is NEVER promoted to the cache and played as if whole,
* - exact-prefix cache lookup that also excludes in-flight `.part` files.
*
* The primary constructor takes the cache dir + client directly so the real download logic is
* unit-testable (see ContentDownloadTest) against a local server without an Android Context; the
* [Context] convenience constructor is what the app uses.
*/
class ContentCache internal constructor(
private val cacheDir: File,
private val client: OkHttpClient
) {
constructor(context: Context) : this(
File(context.filesDir, "content_cache").also { it.mkdirs() },
defaultClient()
)
fun getCachedFile(contentId: String): File? {
val files = cacheDir.listFiles { _, name -> name.startsWith(contentId) }
// Match "<id>.<ext>" exactly: the trailing dot stops an id that PREFIXES another id from
// cross-matching, and `.part` temps (partial/in-flight downloads) are never returned.
val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.endsWith(PART_SUFFIX) }
return files?.firstOrNull()?.takeIf { it.exists() && it.length() > 0 }
}
@ -26,35 +41,61 @@ class ContentCache(private val context: Context) {
}
fun downloadContent(serverUrl: String, contentId: String, filename: String): File? {
val ext = filename.substringAfterLast('.', "mp4")
val finalFile = File(cacheDir, "${contentId}.${ext}")
val partFile = File(cacheDir, "${contentId}.${ext}${PART_SUFFIX}")
try {
val url = "${serverUrl}/api/content/${contentId}/file"
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.e("ContentCache", "Download failed: ${response.code}")
return null
}
val ext = filename.substringAfterLast('.', "mp4")
val file = File(cacheDir, "${contentId}.${ext}")
response.body?.byteStream()?.use { input ->
FileOutputStream(file).use { output ->
input.copyTo(output)
// .use closes the Response (and its body) on every path — also fixes the prior
// error-path body/connection leak.
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.e("ContentCache", "Download failed: ${response.code}")
return null
}
// We issue a plain (no-Range) GET, so a 206 Partial Content means a proxy/CDN
// returned a PARTIAL body whose Content-Length matches that partial — which would
// pass the byte-count integrity check and promote a truncated file. Require a full 200.
if (response.code == 206) {
Log.e("ContentCache", "Refusing 206 Partial Content for $filename — not a complete file")
return null
}
partFile.delete() // clear any earlier partial before writing
val body = response.body ?: return null
val expected = body.contentLength() // -1 when unknown (chunked)
var written = 0L
body.byteStream().use { input ->
FileOutputStream(partFile).use { output -> written = input.copyTo(output) }
}
// Root-2: a truncated body must NOT be promoted to the cache and played as whole.
if (!CacheValidation.isComplete(written, expected)) {
Log.e("ContentCache", "Incomplete download ($written/$expected bytes) for $filename — discarding partial")
partFile.delete()
return null
}
finalFile.delete()
if (!partFile.renameTo(finalFile)) {
Log.e("ContentCache", "Rename failed for $filename")
partFile.delete()
return null
}
Log.i("ContentCache", "Downloaded: $filename -> ${finalFile.absolutePath} ($written bytes)")
return finalFile
}
Log.i("ContentCache", "Downloaded: $filename -> ${file.absolutePath}")
return file
} catch (e: Exception) {
// Includes callTimeout / readTimeout (a stalled download on a healthy socket) and any
// mid-stream break — never leave a partial at the real path.
Log.e("ContentCache", "Download error: ${e.message}")
partFile.delete()
return null
}
}
fun deleteContent(contentId: String) {
cacheDir.listFiles { _, name -> name.startsWith(contentId) }?.forEach { it.delete() }
// Exact-prefix (with the dot) so we don't delete a different id's file — and this also
// sweeps the "<id>.<ext>.part" temp.
cacheDir.listFiles { _, name -> name.startsWith("$contentId.") }?.forEach { it.delete() }
Log.i("ContentCache", "Deleted cached content: $contentId")
}
@ -65,4 +106,15 @@ class ContentCache(private val context: Context) {
fun getCacheSize(): Long {
return cacheDir.listFiles()?.sumOf { it.length() } ?: 0L
}
companion object {
private const val PART_SUFFIX = ".part"
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) // Root-2: a stalled stream (no bytes 30s) aborts (was 5min)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(5, TimeUnit.MINUTES) // Root-2: hard OVERALL cap so a slow-drip can't hang forever
.build()
}
}

View file

@ -0,0 +1,111 @@
package com.remotedisplay.player.data
import android.os.SystemClock
import android.util.Log
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/**
* Resolves the background-download × reconnect A-bucket bug. Previously every playlist sweep spawned
* a detached, un-deduped download `thread{}` writing a deterministic `${id}.ext.part` path. On a
* watchdog / ConnectionGuard reconnect the re-register drove a NEW sweep that started a DUPLICATE
* download of the same content mid-fetch two writers racing the shared `.part` (a corrupt file
* whose byte-count still matched Content-Length, so it passed the integrity check and was promoted),
* plus orphaned threads piling up across reconnects, resurrecting "stuck downloading" through the
* reconnect path the exact failure the caching fix was meant to eliminate.
*
* This coordinator makes background downloads reconnect-resilient:
* - SINGLE-FLIGHT per contentId: a reconnect's re-sweep never starts a second download for content
* already in flight. The in-flight fetch runs on its OWN OkHttp connection (independent of the
* socket), so a reconnect underneath it does not interrupt it it CONTINUES and completes; if
* the network drop truncated it, ContentCache's partial detection discards the `.part` and it
* cleanly RESTARTS on a later sweep. Never orphaned, never a duplicate racing the `.part`.
* - BOUNDED executor: caps concurrent downloads (no thread pileup across reconnects); shutdown()
* cancels in-flight work so nothing orphans or pins the Activity.
* - BACKOFF with failure memory: a permanently-failing URL backs off exponentially instead of
* re-fetching every 60s forever (no retry storm). The screen keeps serving cached content
* (screen-resilience is untouched) and the CMS gets one "failed" ack, not a stuck "downloading".
*
* Atomic-swap + partial detection are ContentCache's job and are unchanged; this only guarantees a
* SINGLE writer per `.part` so that guarantee can't be defeated by a concurrent duplicate.
*/
class DownloadCoordinator(
private val cache: ContentCache,
private val serverUrl: () -> String,
private val socketAlive: () -> Boolean,
private val onAck: (contentId: String, status: String) -> Unit,
private val executor: ExecutorService = Executors.newFixedThreadPool(MAX_CONCURRENT),
private val now: () -> Long = { SystemClock.elapsedRealtime() }
) {
private val inFlight = Collections.synchronizedSet(HashSet<String>())
private val attempts = ConcurrentHashMap<String, Int>()
private val nextAttemptAt = ConcurrentHashMap<String, Long>()
/**
* Ensure [contentId] is (being) downloaded. Called for each local assignment on EVERY playlist
* sweep the 60s refresh and every post-reconnect re-register included. Idempotent and
* non-blocking: it enqueues at most ONE download per contentId and returns immediately.
*/
fun ensure(contentId: String, filename: String) {
if (contentId.isEmpty()) return
if (cache.isContentCached(contentId)) { onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A)
// Socket down => the WATCHDOG owns recovery; don't hammer downloads over a dead connection.
if (!socketAlive()) return
if (now() < (nextAttemptAt[contentId] ?: 0L)) return // in failure backoff — don't storm
if (!inFlight.add(contentId)) return // single-flight: already downloading
try {
executor.execute { runDownload(contentId, filename) }
} catch (e: Throwable) {
inFlight.remove(contentId) // executor rejected (shut down) — don't leak the guard
}
}
private fun runDownload(contentId: String, filename: String) {
try {
val file = cache.downloadContent(serverUrl(), contentId, filename)
if (file != null) {
attempts.remove(contentId); nextAttemptAt.remove(contentId)
// Ack only reaches a live socket; if it dropped, the reconnect's re-register clears
// the ack set and the next sweep re-acks the now-cached file.
if (socketAlive()) onAck(contentId, "ready")
} else {
onFailure(contentId) // includes a reconnect-truncated .part (ContentCache returned null)
}
} catch (e: Throwable) {
Log.w("DownloadCoordinator", "download $contentId failed: ${e.message}")
onFailure(contentId)
} finally {
inFlight.remove(contentId)
}
}
private fun onFailure(contentId: String) {
val n = (attempts[contentId] ?: 0) + 1
attempts[contentId] = n
val delay = minOf(BACKOFF_BASE_MS shl (n - 1).coerceIn(0, 20), BACKOFF_MAX_MS)
nextAttemptAt[contentId] = now() + delay
if (socketAlive()) onAck(contentId, "failed")
}
/** Content deleted server-side — drop download/backoff state so a re-add downloads fresh. */
fun forget(contentId: String) {
inFlight.remove(contentId); attempts.remove(contentId); nextAttemptAt.remove(contentId)
}
/** Teardown (onDestroy): cancel in-flight downloads so none orphan or pin the Activity. */
fun shutdown() {
try { executor.shutdownNow() } catch (_: Throwable) {}
inFlight.clear()
}
// test visibility
internal fun isInFlight(contentId: String) = inFlight.contains(contentId)
companion object {
const val MAX_CONCURRENT = 3
const val BACKOFF_BASE_MS = 15_000L
const val BACKOFF_MAX_MS = 5 * 60_000L
}
}

View file

@ -46,6 +46,14 @@ class MediaPlayerManager(
onVideoComplete()
}
}
// Root-2: a corrupt/undecodable video used to freeze the playlist forever — only
// STATE_ENDED advanced, and an error goes to STATE_IDLE, so onVideoComplete never
// fired. Treat a playback error like a completion so the loop moves on instead of
// wedging on the broken item (mirrors the web/.wgt onerror -> advance).
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e("MediaPlayerManager", "Playback error (${error.errorCodeName}) — advancing: ${error.message}")
onVideoComplete()
}
})
}
}

View file

@ -31,8 +31,13 @@ class PlaylistController(
private val onItemChanged: (PlaylistItem?) -> Unit,
private val onPlaylistEmpty: () -> Unit,
private val onRequestRefresh: (() -> Unit)? = null,
private val onNothingScheduled: (() -> Unit)? = null
private val onNothingScheduled: (() -> Unit)? = null,
// Screen-resilience: the defined "content isn't downloaded yet" waiting state, shown ONLY when
// nothing has ever played (fresh device). Never used while content is on screen.
private val onWaitingForContent: (() -> Unit)? = null
) {
private companion object { const val CONTENT_RECHECK_MS = 3000L }
private val items = mutableListOf<PlaylistItem>()
private var currentIndex = -1
private val handler = Handler(Looper.getMainLooper())
@ -42,6 +47,14 @@ class PlaylistController(
@Volatile private var effectiveTimezone: String? = null
private var retryRunnable: Runnable? = null
// Screen-resilience: an item is playable only when its content is actually AVAILABLE
// (widget / remote-stream / fully-downloaded local file). Injected by MainActivity (which owns
// the cache); the default keeps every item playable so behavior is unchanged until it's set.
private var contentReady: (PlaylistItem) -> Boolean = { true }
fun setContentReadyCheck(f: (PlaylistItem) -> Boolean) { contentReady = f }
// True while a valid item is rendered on screen — so we NEVER blank it for a pending download.
private var hasContentOnScreen = false
// Video wall: followers don't self-advance — the leader's wall:sync drives the index.
private var wallFollower = false
// Wall-clock at which the current item started playing, for non-video sync position.
@ -149,9 +162,13 @@ class PlaylistController(
}
}
// Current item was removed or nothing was playing - start from the first
// schedule-active item; idle if none are active right now.
val idx = firstActiveIndex()
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
// schedule-active AND downloaded item. Distinguish the two idle reasons: daypart
// closed (nothing scheduled) => defined idle; scheduled-but-not-yet-downloaded =>
// keep current content / waiting, never blank.
val fp = PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) }
if (fp >= 0) { currentIndex = fp; playCurrentItem() }
else if (firstActiveIndex() < 0) showNothingScheduled()
else onContentNotReady()
} else {
currentIndex = 0
}
@ -174,11 +191,12 @@ class PlaylistController(
fun start() {
isRunning = true
if (items.isEmpty()) { onPlaylistEmpty(); return }
// #74/#75: begin on the first schedule-active item; idle if none.
val idx = firstActiveIndex()
if (idx < 0) { showNothingScheduled(); return }
currentIndex = idx
playCurrentItem()
// #74/#75: begin on the first schedule-active item; daypart-closed => defined idle.
if (firstActiveIndex() < 0) { showNothingScheduled(); return }
// Screen-resilience: only start on an item whose content is downloaded; if the scheduled
// content isn't ready yet, keep current/wait (never blank on a loading state).
val idx = PlaylistSelection.firstPlayableIndex(items.size) { playableNow(it) }
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
}
fun startIfNeeded() {
@ -200,17 +218,21 @@ class PlaylistController(
isRunning = false
cancelAdvance()
cancelRetry()
hasContentOnScreen = false
}
fun next() {
if (items.isEmpty()) return
// Request a playlist refresh between plays so new content gets picked up
onRequestRefresh?.invoke()
// #74/#75: advance to the next item the schedule allows now; idle if none.
val idx = nextActiveIndex(currentIndex)
if (idx < 0) { showNothingScheduled(); return }
currentIndex = idx
playCurrentItem()
// #74/#75: daypart closed (nothing scheduled now) => defined idle.
if (firstActiveIndex() < 0) { showNothingScheduled(); return }
// Screen-resilience: advance to the next schedule-active AND downloaded item. Unready items
// are SKIPPED (their background download continues), so a stalled/failed download of the
// next content never blanks the screen — we keep looping the content we already have. If
// NOTHING is downloaded yet, keep current content / show the waiting state, never blank.
val idx = PlaylistSelection.nextPlayableIndex(items.size, currentIndex) { playableNow(it) }
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
}
fun onVideoComplete() {
@ -227,6 +249,7 @@ class PlaylistController(
itemStartedAt = System.currentTimeMillis()
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
onItemChanged(item)
hasContentOnScreen = true // a valid item is now rendered — protect it from being blanked
// For images and widgets, auto-advance after duration. For videos, wait
// for the completion callback. Wall followers never auto-advance — the
@ -257,6 +280,40 @@ class PlaylistController(
item.schedules.isEmpty() ||
ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone)
// Playable NOW = schedule-active AND its content is downloaded/available.
private fun playableNow(i: Int): Boolean =
i in items.indices && scheduleAllows(items[i]) && contentReady(items[i])
// Screen-resilience: the scheduled item(s) exist but their content isn't downloaded yet.
// NEVER blank a screen that is already showing content — keep it and re-check soon (the
// background download finishes, or the watchdog restores connectivity). Only show the defined
// waiting/setup state on a fresh device that has never played anything.
private fun onContentNotReady() {
cancelAdvance()
when (PlaylistSelection.whenNonePlayable(hasContentOnScreen)) {
PlaylistSelection.NonePlayable.KEEP_CURRENT -> { /* leave current content on screen */ }
PlaylistSelection.NonePlayable.SHOW_WAITING -> {
hasContentOnScreen = false
(onWaitingForContent ?: onNothingScheduled ?: onPlaylistEmpty)()
}
}
scheduleContentRecheck()
}
// Re-evaluate playability shortly (a pending download may have finished / a daypart opened).
// Faster than the schedule re-check because a finished download should play promptly.
private fun scheduleContentRecheck() {
cancelRetry()
retryRunnable = Runnable {
if (isRunning && items.isNotEmpty()) {
if (firstActiveIndex() < 0) { showNothingScheduled(); return@Runnable }
val idx = PlaylistSelection.nextPlayableIndex(items.size, currentIndex) { playableNow(it) }
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
}
}
handler.postDelayed(retryRunnable!!, CONTENT_RECHECK_MS)
}
private fun firstActiveIndex(): Int {
for (i in items.indices) if (scheduleAllows(items[i])) return i
return -1
@ -275,6 +332,7 @@ class PlaylistController(
// daypart may open. (Boundary re-evaluation otherwise happens on advance.)
private fun showNothingScheduled() {
cancelAdvance()
hasContentOnScreen = false // the daypart genuinely closed — a defined idle, not a blank-bug
(onNothingScheduled ?: onPlaylistEmpty)()
cancelRetry()
retryRunnable = Runnable {

View file

@ -0,0 +1,46 @@
package com.remotedisplay.player.player
/**
* Pure, unit-testable playlist SELECTION + screen-resilience decisions (no Android deps, same
* pattern as ConnectionGuard / OtaThrottle). PlaylistController is the imperative shell (Handler /
* playback); this owns "which item can play right now" and "what to do when none can", so the
* viewer-visible invariant has real coverage:
*
* a pending/failed/stalled content download must NEVER blank or freeze a screen that is showing
* content the player keeps showing what it already has and only swaps to new content once it's
* fully + validly downloaded.
*/
object PlaylistSelection {
/** First index for which [isPlayable] holds, or -1 if none. */
fun firstPlayableIndex(size: Int, isPlayable: (Int) -> Boolean): Int {
for (i in 0 until size) if (isPlayable(i)) return i
return -1
}
/**
* Next index after [from] (wrapping) for which [isPlayable] holds, or -1 if none. With a single
* playable item it returns that item (loop), so a device keeps looping the content it HAS while
* other items are still downloading.
*/
fun nextPlayableIndex(size: Int, from: Int, isPlayable: (Int) -> Boolean): Int {
if (size <= 0) return -1
for (i in 1..size) {
val idx = (((from + i) % size) + size) % size
if (isPlayable(idx)) return idx
}
return -1
}
enum class NonePlayable { KEEP_CURRENT, SHOW_WAITING }
/**
* When nothing is playable (e.g. the scheduled item's content isn't downloaded yet): NEVER
* blank a screen that is showing content. Keep the current content if we have some on screen;
* only fall to the defined waiting/setup state when there is genuinely nothing displayed yet
* (a fresh device that has never successfully played anything). This is the one decision that
* separates "nothing to show yet" (acceptable) from "had content but blanked while updating"
* (the bug this fix forbids).
*/
fun whenNonePlayable(hasContentOnScreen: Boolean): NonePlayable =
if (hasContentOnScreen) NonePlayable.KEEP_CURRENT else NonePlayable.SHOW_WAITING
}

View file

@ -0,0 +1,61 @@
package com.remotedisplay.player.service
import android.content.Context
import com.remotedisplay.player.data.ServerConfig
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* Exit-signal contract v1 best-effort "last gasp" (manner of death), APK conformance.
*
* Sent via a BLOCKING OkHttp POST to /api/device/exit, NOT socket.emit: the socket emit path is
* async with no flush, so it will not reliably leave the buffer before the process dies. A short,
* bounded blocking POST from the crashing thread (about to die anyway) / a worker thread is the
* reliable transport on Android (matches the beacon the browser/Tizen clients use).
*
* Categories (honesty by construction only ever these two; anything else -> server infers 'silent'):
* - "crashed" : the global uncaught-exception handler fired (RemoteDisplayApp).
* - "clean_exit" : Service.onDestroy on COOPERATIVE teardown (stopService/unbind/memory-reclaim-with-
* grace). NOT onStop/onPause (those fire on backgrounding). force-stop / MDM-uninstall
* / SIGKILL / OOM skip all callbacks -> nothing is sent -> server infers 'silent'.
* Idempotent: the first confident signal wins (a crash is never relabelled clean_exit).
*/
object ExitSignal {
@Volatile private var sent = false
private val JSON = "application/json".toMediaType()
private val client = OkHttpClient.Builder()
.callTimeout(2, TimeUnit.SECONDS)
.connectTimeout(2, TimeUnit.SECONDS)
.writeTimeout(2, TimeUnit.SECONDS)
.build()
fun send(context: Context, reason: String, detail: String?) {
try {
if (sent) return
if (reason != "crashed" && reason != "clean_exit") return
val cfg = ServerConfig(context.applicationContext)
val id = cfg.deviceId
val token = cfg.deviceToken
val url = cfg.serverUrl
if (id.isEmpty() || token.isEmpty() || url.isEmpty()) return // unpaired -> nothing to attribute
sent = true
val payload = JSONObject().apply {
put("device_id", id)
put("device_token", token)
put("reason", reason)
if (!detail.isNullOrBlank()) put("detail", detail.take(200))
}.toString()
val req = Request.Builder()
.url(url.trimEnd('/') + "/api/device/exit")
.post(payload.toRequestBody(JSON))
.build()
client.newCall(req).execute().use { /* fire-and-forget; response ignored */ }
} catch (t: Throwable) {
/* a dying process must never throw further out of the last gasp */
}
}
}

View file

@ -0,0 +1,60 @@
package com.remotedisplay.player.service
/**
* v4 canonical liveness contract the pure, unit-testable decision logic for the client-side
* half-open watchdog (no Android / Socket.IO deps, same pattern as [ConnectionGuard] /
* [OtaThrottle]). WebSocketService is the imperative shell: it tracks lastServerMessageAt (ANY
* inbound refreshes it), arms only after a device:heartbeat-ack (degrade-safe), and on each
* heartbeat tick asks THIS object whether a connected-but-silent socket is half-open and, if so,
* whether the anti-thundering-herd backoff allows a reconnect now.
*
* ANTI-THUNDERING-HERD (must not become the flood #143/#149 fixed): the THRESHOLD is jittered
* (45s ± up to 10s) so a fleet doesn't all declare half-open at once under a shared cause (server
* load delaying acks), and the reconnect BACKOFF is exponential-with-jitter (1,2,4,8,16 capped,
* ±20%) so repeated failures spread out and back off instead of hammering. Load-adaptation is by
* the client's OWN ack-silence there is deliberately NO status/health poll (that is itself a
* second herd).
*
* Canonical defaults match the .wgt and /player so all three exhibit identical on-the-wire
* behavior; a platform-driven deviation would be documented here.
*/
object LivenessWatchdog {
const val THRESHOLD_BASE_MS = 45_000L // v4 canonical: 45s …
const val THRESHOLD_JITTER_MS = 10_000L // … ± up to 10s
const val BACKOFF_BASE_MS = 1_000L // v4 canonical: 1s, 2s, 4s, 8s, 16s …
const val BACKOFF_CAP_MS = 30_000L // … capped (within the contract's ~3060s band)
private const val BACKOFF_JITTER_FRACTION = 0.2 // … each ± ~20%
/**
* Watchdog threshold with jitter: 45s ± up to 10s. [rand] is a uniform value in [0, 1)
* (injected so the pure logic is testable); the result is in [35s, 55s).
*/
fun thresholdMs(rand: Double): Long =
THRESHOLD_BASE_MS + ((rand - 0.5) * 2 * THRESHOLD_JITTER_MS).toLong()
/**
* Exponential reconnect backoff with ±20% jitter. [attempt] is 1-based (the 1st reconnect
* uses the 1s step). Steps double (1s,2s,4s,8s,16s,) and saturate at [BACKOFF_CAP_MS]; the
* shift is clamped so a large attempt count can't overflow.
*/
fun backoffMs(attempt: Int, rand: Double): Long {
val steps = (attempt - 1).coerceIn(0, 20)
val base = minOf(BACKOFF_BASE_MS shl steps, BACKOFF_CAP_MS)
val jitter = ((rand - 0.5) * 2 * BACKOFF_JITTER_FRACTION * base).toLong()
return base + jitter
}
/**
* HALF-OPEN decision: reconnect only when a socket that is [connected] (Socket.IO still
* reports it up the state its own auto-reconnect can't see) and whose liveness we have
* [armed] (seen 1 heartbeat-ack degrade-safe: an ack-less/old server never arms us) has
* been silent longer than [thresholdMs]. Pure; mirrors the .wgt's watchdogShouldReconnect.
*/
fun isHalfOpen(armed: Boolean, connected: Boolean, silenceMs: Long, thresholdMs: Long): Boolean =
armed && connected && silenceMs > thresholdMs
/** Backoff gate: a new reconnect attempt is allowed only once the backoff for the current
* attempt count has elapsed since the last one spaces repeated failures fleet-wide. */
fun mayReconnectNow(msSinceLastAttempt: Long, backoffMs: Long): Boolean =
msSinceLastAttempt >= backoffMs
}

View file

@ -8,7 +8,9 @@ import android.os.Binder
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import kotlin.random.Random
import androidx.core.app.NotificationCompat
import com.remotedisplay.player.MainActivity
import com.remotedisplay.player.RemoteDisplayApp
@ -35,6 +37,20 @@ class WebSocketService : Service() {
private var heartbeatRunnable: Runnable? = null
private val binder = LocalBinder()
// v4 liveness watchdog state (see LivenessWatchdog for the pure decision logic).
// lastServerMessageAt: ANY inbound server message refreshes it (markAlive, wired into safeOn) —
// uses the monotonic elapsedRealtime clock so an NTP/wall-clock jump can't false-fire or blind
// the watchdog. livenessConfirmed: ARMED only after a device:heartbeat-ack (degrade-safe — an
// ack-less/old server never arms us, so no false-fire storm). currentThresholdMs: per-connection
// jittered 45s ± up to 10s. watchdogAttempt/lastWatchdogAttemptAt: exponential-backoff gate.
@Volatile private var lastServerMessageAt = 0L
@Volatile private var livenessConfirmed = false
@Volatile private var currentThresholdMs = LivenessWatchdog.THRESHOLD_BASE_MS
private var watchdogAttempt = 0
private var lastWatchdogAttemptAt = 0L
private fun markAlive() { lastServerMessageAt = SystemClock.elapsedRealtime() }
companion object {
// #148: backoff before re-opening the single socket after a disconnect that Socket.IO
// does NOT auto-reconnect (io server/client disconnect) — never a blind immediate re-open.
@ -101,6 +117,10 @@ class WebSocketService : Service() {
// exception on the Socket.IO IO thread and crash the whole app.
private fun Socket.safeOn(event: String, handler: (Array<Any?>) -> Unit): Socket {
on(event) { args ->
// v4: ANY inbound server message refreshes liveness (not just acks). The half-open
// decision additionally requires socket.connected(), so refreshing on a disconnect
// event is harmless. This is the single central receive-path hook.
markAlive()
try {
@Suppress("UNCHECKED_CAST")
handler(args as Array<Any?>)
@ -138,6 +158,14 @@ class WebSocketService : Service() {
currentUrl = url
socketActive = true
// v4 watchdog: a fresh socket is assumed alive; DIS-arm until it earns an ack again
// (degrade-safe), and pick a new jittered threshold for this connection so a fleet doesn't
// declare half-open in lockstep. watchdogAttempt is intentionally NOT reset here — it
// tracks repeated reconnect failures across sockets and resets on a healthy ack.
lastServerMessageAt = SystemClock.elapsedRealtime()
livenessConfirmed = false
currentThresholdMs = LivenessWatchdog.thresholdMs(Random.nextDouble())
try {
val options = IO.Options().apply {
forceNew = true
@ -196,6 +224,17 @@ class WebSocketService : Service() {
startHeartbeat()
}
// v4 degrade-safe ARM: the watchdog arms ONLY after the first heartbeat-ack, so a
// server that never acks (old/pre-contract) never arms us -> no false-fire storm.
// markAlive already fired via safeOn (any inbound); a healthy ack also resets the
// reconnect backoff. Known ack-gap (reconnecting-not-yet-re-registered) is benign:
// arm-after-ack + any-inbound-refresh keep the watchdog from firing in that window.
safeOn("device:heartbeat-ack") {
if (!livenessConfirmed) Log.i("WebSocketService", "v4 watchdog: ARMED (first heartbeat-ack)")
livenessConfirmed = true
watchdogAttempt = 0
}
safeOn("device:unpaired") {
Log.w("WebSocketService", "Device not found on server - clearing credentials")
config.clearDeviceCredentials()
@ -380,6 +419,19 @@ class WebSocketService : Service() {
}
}
// v4 client identity block — additive, canonical snake_case (same field shape as the .wgt and
// /player), piggybacked on the register message the client already sends. Backward-compatible:
// an old server ignores unknown fields. Capture-don't-act — the server stores it; no client
// logic is built on it here.
private fun JSONObject.putIdentity() {
try {
put("client_type", "apk")
put("client_version", deviceInfo.getAppVersion())
put("platform", "Android " + android.os.Build.VERSION.RELEASE)
put("contract_version", "v4")
} catch (e: Throwable) { Log.w("WebSocketService", "identity: ${e.message}") }
}
private fun register() {
try {
val data = JSONObject().apply {
@ -398,6 +450,7 @@ class WebSocketService : Service() {
}
try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
try { put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") }
putIdentity()
}
socket?.emit("device:register", data)
} catch (e: Throwable) {
@ -417,18 +470,60 @@ class WebSocketService : Service() {
heartbeatCount = 0
heartbeatRunnable = object : Runnable {
override fun run() {
// A watchdog reconnect (below) tears down + restarts the heartbeat; if this is a
// stale runnable superseded by that restart, stop — never let two loops run.
if (heartbeatRunnable !== this) return
sendHeartbeat()
heartbeatCount++
// Every 4th heartbeat (60s), request a fresh playlist
if (heartbeatCount % 4 == 0) {
requestPlaylistRefresh()
}
// v4 liveness watchdog: runs on the heartbeat tick (i.e. only while we've been
// SENDING heartbeats). If it detects+acts on a half-open socket it tears this loop
// down and a fresh one starts on re-register, so do NOT reschedule this one.
if (checkHalfOpenAndReconnect()) return
handler.postDelayed(this, 15000) // Every 15 seconds
}
}
handler.post(heartbeatRunnable!!)
}
/**
* v4 half-open detection. On a HALF-OPEN socket Socket.IO still reports connected()==true
* (its own auto-reconnect can't see server-silence), so we detect it here: armed (saw an ack)
* + connected + silent past the jittered threshold. Returns true iff it triggered a reconnect
* (so the heartbeat loop stops). The exponential backoff gate spaces repeated attempts so the
* watchdog can't become the flood #143/#149 fixed. No status/health poll load is read from
* our own ack-silence.
*/
private fun checkHalfOpenAndReconnect(): Boolean {
val connected = socket?.connected() == true
val silenceMs = SystemClock.elapsedRealtime() - lastServerMessageAt
if (!LivenessWatchdog.isHalfOpen(livenessConfirmed, connected, silenceMs, currentThresholdMs)) return false
val sinceAttempt = SystemClock.elapsedRealtime() - lastWatchdogAttemptAt
val backoff = LivenessWatchdog.backoffMs(watchdogAttempt + 1, Random.nextDouble())
if (!LivenessWatchdog.mayReconnectNow(sinceAttempt, backoff)) return false
watchdogAttempt++
lastWatchdogAttemptAt = SystemClock.elapsedRealtime()
Log.w("WebSocketService", "v4 watchdog: HALF-OPEN (silent ${silenceMs}ms > ${currentThresholdMs}ms, attempt=$watchdogAttempt) — teardown+reconnect (#148)")
reconnectHalfOpen()
return true
}
/**
* Teardown-before-reopen (#148) for a half-open socket. disconnect() kills the dead socket AND
* its listeners/auto-reconnect FIRST (so we don't race Socket.IO's own reconnect), then
* connect() with socket=null + socketActive=false opens exactly ONE fresh socket via the
* ConnectionGuard. @Synchronized so it can't interleave with a racing connect()/openSocket().
* The watchdog LAYERS ON the existing #148 guard; it does not replace it.
*/
@Synchronized
private fun reconnectHalfOpen() {
disconnect()
connect()
}
fun requestPlaylistRefresh() {
if (socket?.connected() != true || config.deviceId.isEmpty()) return
Log.i("WebSocketService", "Requesting playlist refresh")
@ -438,6 +533,7 @@ class WebSocketService : Service() {
val token = config.deviceToken
if (token.isNotEmpty()) put("device_token", token)
try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
putIdentity()
}
socket?.emit("device:register", data)
} catch (e: Throwable) {
@ -695,6 +791,19 @@ class WebSocketService : Service() {
private set
override fun onDestroy() {
// Exit-signal contract v1 — 'clean_exit' (BEST-EFFORT). onDestroy runs ONLY on cooperative
// teardown (stopService/unbind/memory-reclaim-with-grace); a force-stop / MDM-uninstall / SIGKILL
// skips it entirely -> the server infers 'silent' (correct — not misclassified). Try the still-
// live socket first, then a bounded blocking beacon (the reliable path); the server dedups.
try {
if (socket?.connected() == true && config.deviceId.isNotEmpty()) {
socket?.emit("device:exit", JSONObject().apply {
put("device_id", config.deviceId); put("reason", "clean_exit"); put("detail", "onDestroy")
})
}
} catch (e: Throwable) { /* never let the last gasp block teardown */ }
val ctx = applicationContext
Thread { ExitSignal.send(ctx, "clean_exit", "onDestroy") }.apply { start(); try { join(1500) } catch (e: InterruptedException) { /* proceed with teardown */ } }
wakeLock?.let { if (it.isHeld) it.release() }
disconnect()
super.onDestroy()

View file

@ -174,7 +174,7 @@ class DeviceInfo(private val context: Context) {
return dm.widthPixels to dm.heightPixels
}
private fun getAppVersion(): String {
fun getAppVersion(): String {
return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
} catch (e: Exception) {

View file

@ -0,0 +1,30 @@
package com.remotedisplay.player.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** Root-2: the partial/truncated-download detection rule that keeps a broken file out of the cache. */
class CacheValidationTest {
@Test fun `full download with known length is complete`() {
assertTrue(CacheValidation.isComplete(bytesWritten = 100, expectedBytes = 100))
}
@Test fun `truncated download (fewer bytes than declared) is INCOMPLETE`() {
assertFalse(CacheValidation.isComplete(bytesWritten = 40, expectedBytes = 100))
}
@Test fun `over-read (more than declared) is treated as INCOMPLETE, not promoted`() {
assertFalse(CacheValidation.isComplete(bytesWritten = 150, expectedBytes = 100))
}
@Test fun `unknown length (chunked, -1) with bytes falls back to complete`() {
assertTrue(CacheValidation.isComplete(bytesWritten = 100, expectedBytes = -1))
}
@Test fun `zero bytes is never complete`() {
assertFalse(CacheValidation.isComplete(bytesWritten = 0, expectedBytes = -1))
assertFalse(CacheValidation.isComplete(bytesWritten = 0, expectedBytes = 100))
}
}

View file

@ -0,0 +1,119 @@
package com.remotedisplay.player.data
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.OutputStream
import java.net.ServerSocket
import java.nio.file.Files
import java.util.concurrent.TimeUnit
/**
* Root-2 REPRODUCE-THEN-PROVE for the "stuck downloading / frozen" caching bug. Each test drives
* the REAL ContentCache.downloadContent against a local HTTP server that reproduces a specific
* failure mode on a HEALTHY socket (the socket is fine the DOWNLOAD misbehaves), and proves the
* fix: a stalled/trickling download aborts instead of hanging forever, and a truncated body is
* never promoted to the cache (so it can't be played as if whole and wedge the playlist).
*
* The client uses short timeouts so the STALL reproduction is fast; the download/validation logic
* exercised is identical to production (only the timeout VALUES differ production is
* callTimeout=5min / readTimeout=30s, verified by inspection in ContentCache.defaultClient()).
*/
class ContentDownloadTest {
private lateinit var dir: java.io.File
private lateinit var cache: ContentCache
private var server: ServerSocket? = null
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(1, TimeUnit.SECONDS) // a stalled stream aborts in ~1s => fast test
.callTimeout(3, TimeUnit.SECONDS) // hard overall cap backstop
.build()
@Before fun setUp() {
dir = Files.createTempDirectory("cachetest").toFile()
cache = ContentCache(dir, client) // internal ctor — real logic, no Android Context
}
@After fun tearDown() {
try { server?.close() } catch (_: Exception) {}
dir.deleteRecursively()
}
/** Accept ONE connection, drain the request, then let [respond] write a crafted response. */
private fun serveOnce(respond: (OutputStream) -> Unit): String {
val s = ServerSocket(0)
server = s
Thread {
try {
s.accept().use { sock ->
val reader = sock.getInputStream().bufferedReader()
while (true) { val line = reader.readLine() ?: break; if (line.isEmpty()) break }
respond(sock.getOutputStream())
}
} catch (_: Exception) { /* client hung up on timeout — expected for the stall case */ }
}.apply { isDaemon = true; start() }
return "http://127.0.0.1:${s.localPort}"
}
private fun OutputStream.writeHttp(contentLength: Int, body: ByteArray) {
write("HTTP/1.1 200 OK\r\nContent-Length: $contentLength\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray())
write(body)
flush()
}
private fun partFiles() = dir.listFiles { _, name -> name.endsWith(".part") }?.toList() ?: emptyList()
// ---- positive control: a complete download IS cached ----
@Test fun `complete download is cached with the right size and no leftover part file`() {
val url = serveOnce { it.writeHttp(5, "hello".toByteArray()) }
val file = cache.downloadContent(url, "cidA", "clip.bin")
assertNotNull("a complete download should be cached", file)
assertEquals(5L, file!!.length())
assertNotNull(cache.getCachedFile("cidA"))
assertTrue("no .part temp should remain", partFiles().isEmpty())
}
// ---- REPRODUCE: truncated body (declares 100 bytes, sends 40 then closes) on a healthy socket ----
@Test fun `truncated download is NOT promoted to the cache — partial detected and discarded`() {
val url = serveOnce {
it.writeHttp(100, ByteArray(40) { 'x'.code.toByte() })
// close after 40 of the declared 100 bytes -> truncation
}
val file = cache.downloadContent(url, "cidB", "clip.bin")
assertNull("a truncated download must return null (not a usable file)", file)
assertNull("a truncated file must NOT be served as cached", cache.getCachedFile("cidB"))
assertTrue("the partial .part must be cleaned up, not left behind", partFiles().isEmpty())
}
// ---- REPRODUCE: a STALLED download (headers + a trickle, then hang) on a healthy socket ----
@Test fun `stalled download aborts within the timeout instead of hanging forever`() {
val url = serveOnce {
it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray())
it.write(ByteArray(10)); it.flush()
Thread.sleep(10_000) // hang mid-stream — the OLD client (5min readTimeout) waited here
}
val start = System.currentTimeMillis()
val file = cache.downloadContent(url, "cidC", "clip.bin")
val elapsed = System.currentTimeMillis() - start
assertNull("a stalled download must fail, not hang", file)
assertTrue("must abort quickly via the timeout (was ~$elapsed ms)", elapsed < 5_000)
assertNull(cache.getCachedFile("cidC"))
assertTrue("no partial left behind after a stall", partFiles().isEmpty())
}
// ---- prefix cross-match guard: an id that prefixes another must not match ----
@Test fun `getCachedFile does not cross-match an id that is a prefix of another`() {
serveOnce { it.writeHttp(3, "abc".toByteArray()) }.let { url ->
assertNotNull(cache.downloadContent(url, "abc", "x.bin"))
}
assertNotNull(cache.getCachedFile("abc"))
assertNull("id 'ab' must NOT match cached 'abc.x'", cache.getCachedFile("ab"))
}
}

View file

@ -0,0 +1,194 @@
package com.remotedisplay.player.data
import com.remotedisplay.player.player.PlaylistSelection
import com.remotedisplay.player.service.ConnectionGuard
import com.remotedisplay.player.service.LivenessWatchdog
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import java.io.OutputStream
import java.net.ServerSocket
import java.nio.file.Files
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* Reproduce-then-prove for the background-download × reconnect A-bucket bug. Drives the REAL
* DownloadCoordinator + ContentCache against a local server that can be GATED (held mid-response)
* so a download is genuinely in flight when a reconnect is simulated underneath it.
*/
class DownloadCoordinatorTest {
private lateinit var dir: java.io.File
private lateinit var cache: ContentCache
private lateinit var executor: ExecutorService
private lateinit var coord: DownloadCoordinator
private var server: ServerSocket? = null
private val requests = AtomicInteger(0)
private val acks = Collections.synchronizedList(mutableListOf<String>()) // "id:status"
@Volatile private var socketUp = true
@Volatile private var clock = 0L
private var baseUrl = ""
private val client = OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS).readTimeout(2, TimeUnit.SECONDS).callTimeout(4, TimeUnit.SECONDS).build()
@Before fun setUp() {
dir = Files.createTempDirectory("coord").toFile()
cache = ContentCache(dir, client)
executor = Executors.newFixedThreadPool(2)
requests.set(0); acks.clear(); socketUp = true; clock = 0L
coord = DownloadCoordinator(cache, { baseUrl }, { socketUp }, { c, s -> acks.add("$c:$s") }, executor, { clock })
}
@After fun tearDown() {
try { coord.shutdown() } catch (_: Exception) {}
try { executor.shutdownNow() } catch (_: Exception) {}
server?.close(); dir.deleteRecursively()
}
// A server that accepts connections in a loop; each: counts, fires `arrived` on the 1st, awaits
// `gate` (if any), then writes `respond` and closes. Multiple connections are accepted, so a
// buggy DUPLICATE download shows up as requests==2.
private fun serve(gate: CountDownLatch?, arrived: CountDownLatch?, respond: (OutputStream) -> Unit) {
val s = ServerSocket(0); server = s; baseUrl = "http://127.0.0.1:${s.localPort}"
Thread {
try {
while (!s.isClosed) {
val sock = s.accept()
if (requests.incrementAndGet() == 1) arrived?.countDown()
Thread {
try {
val r = sock.getInputStream().bufferedReader()
while (true) { val l = r.readLine() ?: break; if (l.isEmpty()) break }
gate?.await(5, TimeUnit.SECONDS)
respond(sock.getOutputStream()); sock.close()
} catch (_: Exception) {}
}.apply { isDaemon = true; start() }
}
} catch (_: Exception) {}
}.apply { isDaemon = true; start() }
}
private fun full(body: String): (OutputStream) -> Unit = { o ->
o.write("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n".toByteArray()); o.write(body.toByteArray()); o.flush()
}
private fun status(line: String): (OutputStream) -> Unit = { o ->
o.write("HTTP/1.1 $line\r\nContent-Length: 0\r\n\r\n".toByteArray()); o.flush()
}
private fun partFiles() = dir.listFiles { _, n -> n.endsWith(".part") }?.size ?: 0
private fun await(l: CountDownLatch, what: String) = assertTrue("timeout waiting for $what", l.await(5, TimeUnit.SECONDS))
private fun waitAck(key: String) { for (i in 0..60) { if (acks.contains(key)) return; Thread.sleep(100) }; fail("no ack '$key' (got $acks)") }
// ===== THE REPRODUCE-THEN-PROVE: single-flight survives a reconnect mid-download =====
@Test fun `reconnect mid-download starts NO duplicate — one request, in-flight fetch completes cleanly`() {
val gate = CountDownLatch(1); val arrived = CountDownLatch(1)
serve(gate, arrived, full("HELLObytes"))
coord.ensure("X", "v.bin") // download starts, blocks on the gate — genuinely in flight
await(arrived, "the download to reach the server")
assertTrue("X is in flight", coord.isInFlight("X"))
// SIMULATE THE RECONNECT: re-register drives more sweeps for the SAME content
coord.ensure("X", "v.bin"); coord.ensure("X", "v.bin")
Thread.sleep(300) // give any (buggy) duplicate time to connect
assertEquals("single-flight: exactly ONE request despite reconnect re-sweeps", 1, requests.get())
gate.countDown() // let the in-flight download finish on its own connection
waitAck("X:ready")
assertNotNull("the in-flight download completes and is cached (not orphaned/abandoned)", cache.getCachedFile("X"))
assertEquals("no partial left", 0, partFiles())
assertFalse("no longer in flight after completion", coord.isInFlight("X"))
}
// ===== partial safety: a reconnect-truncated download is detected + not swapped =====
@Test fun `reconnect-truncated download is detected as partial, acked failed, never swapped`() {
serve(null, null) { o -> // declares 100, sends 40 then closes — as a mid-fetch network drop would
o.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray()); o.write(ByteArray(40)); o.flush()
}
coord.ensure("Y", "v.bin"); waitAck("Y:failed")
assertNull("a truncated file must NOT be cached/swapped", cache.getCachedFile("Y"))
assertEquals("no partial left behind", 0, partFiles())
}
// ===== permanent failure: backoff, no retry storm =====
@Test fun `a permanently-failing download backs off instead of storming`() {
serve(null, null, status("404 Not Found"))
coord.ensure("Z", "v.bin"); waitAck("Z:failed")
val after1 = requests.get()
repeat(5) { coord.ensure("Z", "v.bin") }; Thread.sleep(300) // clock unchanged -> within backoff
assertEquals("within backoff, NO new attempts (no storm)", after1, requests.get())
clock = 20_000 // past the first 15s backoff step
coord.ensure("Z", "v.bin"); Thread.sleep(300)
assertEquals("after backoff elapses, exactly one more attempt", after1 + 1, requests.get())
}
// ===== socket-down DEFER (ownership: watchdog owns recovery) =====
@Test fun `socket down defers the download to the reconnect, then downloads when back`() {
serve(null, null, full("data"))
socketUp = false
coord.ensure("W", "v.bin"); Thread.sleep(300)
assertEquals("no download while the socket is down", 0, requests.get())
socketUp = true
coord.ensure("W", "v.bin"); waitAck("W:ready")
assertNotNull("downloads once the socket is back", cache.getCachedFile("W"))
}
// ===== teardown cancels in-flight work (no orphan / no Activity pin) =====
@Test fun `shutdown cancels in-flight download and clears state`() {
val gate = CountDownLatch(1); val arrived = CountDownLatch(1)
serve(gate, arrived, full("data"))
coord.ensure("Q", "v.bin"); await(arrived, "in-flight download")
coord.shutdown()
assertFalse("in-flight guard cleared on shutdown (no orphan)", coord.isInFlight("Q"))
Thread.sleep(200)
assertNull("nothing cached — the download was cancelled, not abandoned mid-promote", cache.getCachedFile("Q"))
}
// ===== 206 Partial Content is refused (never promoted as whole) =====
@Test fun `a 206 partial-content response is refused, not promoted`() {
serve(null, null) { o -> // 206 with a matching partial length would pass a naive byte-count check
o.write("HTTP/1.1 206 Partial Content\r\nContent-Length: 3\r\n\r\n".toByteArray()); o.write("abc".toByteArray()); o.flush()
}
coord.ensure("P", "v.bin"); waitAck("P:failed")
assertNull("a 206 partial must not be cached as complete", cache.getCachedFile("P"))
}
// ===== INTEGRATED RE-SOAK: playing -> bg download -> reconnect mid-download -> resume =====
@Test fun `re-soak — one socket, no orphaned download, screen never blanks, download resumes`() {
val onlyZeroReady: (Int) -> Boolean = { it == 0 } // item 0 cached & playing; item 1 downloading
val threshold = LivenessWatchdog.thresholdMs(0.5)
val gate = CountDownLatch(1); val arrived = CountDownLatch(1)
serve(gate, arrived, full("item1data"))
// playing item 0; item 1's background download is in flight
coord.ensure("item1", "v.bin"); await(arrived, "item1 download")
assertEquals("screen keeps playing item 0 (skips the still-downloading item 1)", 0,
PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady))
// server goes silent -> watchdog fires (its own signal); screen must NOT blank
assertTrue(LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 50_000, thresholdMs = threshold))
assertEquals(PlaylistSelection.NonePlayable.KEEP_CURRENT, PlaylistSelection.whenNonePlayable(hasContentOnScreen = true))
// watchdog reconnect -> exactly ONE socket via ConnectionGuard; the re-sweep does NOT dup the download
assertTrue(ConnectionGuard.shouldOpenNewSocket(hasSocket = false, sameUrl = true, socketActive = false))
coord.ensure("item1", "v.bin"); Thread.sleep(200)
assertEquals("reconnect re-sweep did not orphan/duplicate the in-flight download", 1, requests.get())
assertEquals("screen still shows item 0 across the reconnect", 0,
PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady))
// download RESUMES (its own connection was never torn down) and completes cleanly
gate.countDown(); waitAck("item1:ready")
assertNotNull("item 1 downloaded (not orphaned) and is now swappable", cache.getCachedFile("item1"))
assertFalse(coord.isInFlight("item1"))
assertEquals("now both items ready -> screen advances to item 1 (only fully-valid content)", 1,
PlaylistSelection.nextPlayableIndex(2, 0) { true })
}
}

View file

@ -0,0 +1,60 @@
package com.remotedisplay.player.player
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Screen-resilience (FIX 2): a pending/failed/stalled content download must NEVER blank or freeze
* a screen that is showing content. These prove the SELECTION rules that guarantee it unready
* items are skipped (the player keeps looping what it has), and "nothing playable" keeps the
* current content whenever anything is on screen.
*/
class PlaylistSelectionTest {
// helper: items 0..n-1, `ready` are the indices whose content is downloaded/available
private fun readyPredicate(vararg ready: Int): (Int) -> Boolean = { it in ready.toSet() }
// ===== the KEY viewer scenario: playing item 0, item 1's download is stalled =====
@Test fun `a stalled download of the NEXT item does not interrupt current content — it loops what it has`() {
// playlist [0 ready, 1 NOT ready(downloading)]; currently playing 0. Advancing must SKIP 1
// and come back to 0, so the viewer keeps seeing content (no blank, no "Downloading…").
val idx = PlaylistSelection.nextPlayableIndex(size = 2, from = 0, isPlayable = readyPredicate(0))
assertEquals("must skip the un-downloaded item and keep playing item 0", 0, idx)
}
@Test fun `once the download completes the next item is picked up on the following advance`() {
// now item 1 is downloaded too -> advancing from 0 swaps to 1 (only fully-ready content).
val idx = PlaylistSelection.nextPlayableIndex(size = 2, from = 0, isPlayable = readyPredicate(0, 1))
assertEquals(1, idx)
}
// ===== partial-file safety: an un-ready (partial/corrupt) item is never selected =====
@Test fun `an un-ready (partial-download) item is never chosen to play`() {
// items [0 ready, 1 partial/not-ready, 2 ready] -> selection never returns 1.
assertEquals(0, PlaylistSelection.firstPlayableIndex(3, readyPredicate(0, 2)))
assertEquals(2, PlaylistSelection.nextPlayableIndex(3, 0, readyPredicate(0, 2)))
assertEquals(0, PlaylistSelection.nextPlayableIndex(3, 2, readyPredicate(0, 2)))
}
// ===== nothing downloaded yet =====
@Test fun `no item ready returns -1 (nothing to play)`() {
assertEquals(-1, PlaylistSelection.firstPlayableIndex(3, readyPredicate()))
assertEquals(-1, PlaylistSelection.nextPlayableIndex(3, 1, readyPredicate()))
}
// ===== the invariant: never blank while content is on screen =====
@Test fun `nothing-playable KEEPS current content whenever something is on screen (never blanks)`() {
assertEquals(PlaylistSelection.NonePlayable.KEEP_CURRENT,
PlaylistSelection.whenNonePlayable(hasContentOnScreen = true))
}
@Test fun `nothing-playable shows the defined waiting state only when nothing is displayed yet`() {
assertEquals(PlaylistSelection.NonePlayable.SHOW_WAITING,
PlaylistSelection.whenNonePlayable(hasContentOnScreen = false))
}
// ===== single downloaded item loops (doesn't blank waiting for others) =====
@Test fun `a single downloaded item loops instead of blanking`() {
assertEquals(0, PlaylistSelection.nextPlayableIndex(1, 0, readyPredicate(0)))
}
}

View file

@ -0,0 +1,172 @@
package com.remotedisplay.player.service
import com.remotedisplay.player.data.ContentCache
import com.remotedisplay.player.player.PlaylistSelection
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.OutputStream
import java.net.ServerSocket
import java.nio.file.Files
import java.util.concurrent.TimeUnit
/**
* APK VERIFY pass the ASSEMBLY soak. Per-part tests prove the pieces; this proves the
* caching-state-machine × liveness-watchdog INTERACTION (the novel APK-only behavior) does not
* step on itself, driving the REAL ContentCache download + the REAL LivenessWatchdog decisions
* through the lifecycle sequence.
*
* Scope note (honest): the Handler/Looper/Socket.IO runtime can't run in a plain JVM unit test
* (no Robolectric in this module), so socket-teardown/timer wiring is verified by code-trace +
* ConnectionGuard's decisions; the DOWNLOAD path and every WATCHDOG DECISION here are the real
* production code exercised with real time.
*/
class AssemblyInteractionTest {
private lateinit var dir: java.io.File
private lateinit var cache: ContentCache
private var server: ServerSocket? = null
private val client = OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(1, TimeUnit.SECONDS)
.callTimeout(3, TimeUnit.SECONDS)
.build()
@Before fun setUp() {
dir = Files.createTempDirectory("assembly").toFile()
cache = ContentCache(dir, client)
}
@After fun tearDown() {
try { server?.close() } catch (_: Exception) {}
dir.deleteRecursively()
}
private fun serveOnce(respond: (OutputStream) -> Unit): String {
val s = ServerSocket(0); server = s
Thread {
try {
s.accept().use { sock ->
val r = sock.getInputStream().bufferedReader()
while (true) { val l = r.readLine() ?: break; if (l.isEmpty()) break }
respond(sock.getOutputStream())
}
} catch (_: Exception) {}
}.apply { isDaemon = true; start() }
return "http://127.0.0.1:${s.localPort}"
}
private fun serveComplete(body: String) = serveOnce {
it.write("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n".toByteArray())
it.write(body.toByteArray()); it.flush()
}
private fun serveStall() = serveOnce {
it.write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray())
it.write(ByteArray(10)); it.flush(); Thread.sleep(10_000)
}
private fun partFiles() = dir.listFiles { _, n -> n.endsWith(".part") }?.toList() ?: emptyList()
// ===== VERIFY 1: reconnect ownership — watchdog routes through ConnectionGuard (single owner) =====
@Test fun `watchdog reconnect opens exactly one socket via ConnectionGuard, never its own`() {
// reconnectHalfOpen() = disconnect() [socket=null, socketActive=false] then connect(), and
// connect() consults ConnectionGuard. After teardown -> open exactly ONE.
assertTrue(ConnectionGuard.shouldOpenNewSocket(hasSocket = false, sameUrl = true, socketActive = false))
// and a live/self-healing socket is REUSED (single-owner: never a second socket alongside).
assertFalse(ConnectionGuard.shouldOpenNewSocket(hasSocket = true, sameUrl = true, socketActive = true))
}
// ===== VERIFY 3 CHECK A: a caching stall/retry must NOT look like server-silence =====
@Test fun `a stalled download does NOT trip the watchdog while the socket keeps getting acks`() {
// Real architecture: downloads run on their own thread; socket liveness is refreshed by
// heartbeat-acks (any inbound) every 15s, INDEPENDENT of download activity. Walk a 60s
// stall; an ack lands every 15s. Silence must never cross the (jitter-floor) threshold.
val threshold = LivenessWatchdog.thresholdMs(0.0) // 35s — the tightest (worst-case) threshold
var lastServerMs = 0L
var spurious = false
for (t in 0..60_000 step 1_000) {
if (t % 15_000 == 0) lastServerMs = t.toLong() // ack refresh — decoupled from the download
if (LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = t - lastServerMs, thresholdMs = threshold))
spurious = true
}
assertFalse("a download stall must NOT cause a watchdog reconnect while the socket is healthy", spurious)
}
// ===== VERIFY 3 CHECK B: server-silence DURING a download -> watchdog fires; download unharmed =====
@Test fun `server silence during a download fires the watchdog AND the download state stays consistent`() {
// The watchdog (server-silence) and the download (its own thread + .part file) are separate
// subsystems: the watchdog tears down the SOCKET, never the download or its files.
val threshold = LivenessWatchdog.thresholdMs(0.5) // 45s
assertTrue("50s of server silence is half-open", LivenessWatchdog.isHalfOpen(true, true, 50_000, threshold))
val f = cache.downloadContent(serveComplete("payload!"), "cidB", "v.bin")
assertNotNull("the download completes independently of a socket reconnect", f)
assertEquals(8L, f!!.length())
assertTrue("no partial left — download files untouched by any reconnect", partFiles().isEmpty())
}
// ===== VERIFY 3 CHECK C: no double-action — retry then success leaves ONE consistent file =====
@Test fun `caching-timeout retry and watchdog do not double-act — retry yields one consistent file`() {
// 1st attempt stalls -> caching timeout -> null, partial cleaned (NOT a watchdog concern).
assertNull(cache.downloadContent(serveStall(), "cidC", "v.bin"))
assertTrue("stall leaves no orphan .part", partFiles().isEmpty())
assertNull("stall does not promote a partial", cache.getCachedFile("cidC"))
// retry (healthy) -> exactly one cached file, no duplicates/orphans.
val f = cache.downloadContent(serveComplete("retry-ok"), "cidC", "v.bin")
assertNotNull(f); assertEquals(8L, f!!.length())
assertEquals("exactly one cached artifact for the id", 1,
dir.listFiles { _, n -> n.startsWith("cidC.") }?.size ?: 0)
}
// ===== VERIFY 4: degrade-safe + the deferred ack-gap window =====
@Test fun `degrade-safe — an ack-less server never arms the watchdog, even when very silent`() {
assertFalse(LivenessWatchdog.isHalfOpen(armed = false, connected = true, silenceMs = 10 * 60_000, thresholdMs = 45_000))
}
// ===== FIX 1 + FIX 2 re-soak: through the whole sequence the SCREEN NEVER BLANKS =====
@Test fun `re-soak — stall then ack-silence then watchdog reconnect then resume, screen never blanks`() {
// Device is PLAYING item 0 (downloaded); item 1's download is stalled. Walk every stage.
val onlyZeroReady: (Int) -> Boolean = { it == 0 }
val bothReady: (Int) -> Boolean = { true }
val threshold = LivenessWatchdog.thresholdMs(0.5)
// 1) healthy, item 1 download STALLS on a live socket -> advancing SKIPS 1 and keeps 0
// (a playable index is always found, so the screen shows content — never blanks), and
// the stall does NOT trip the watchdog.
assertEquals("skip the stalled item, keep playing 0", 0,
PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady))
assertFalse("download stall on a live socket must not trip the watchdog",
LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 5_000, thresholdMs = threshold))
// 2) server goes SILENT -> the watchdog fires on ITS signal, but the screen keeps content
assertTrue(LivenessWatchdog.isHalfOpen(true, true, 50_000, threshold))
assertEquals("a reconnect must never blank a screen showing content",
PlaylistSelection.NonePlayable.KEEP_CURRENT, PlaylistSelection.whenNonePlayable(hasContentOnScreen = true))
// 3) watchdog reconnect -> EXACTLY ONE socket via ConnectionGuard (single owner)
assertTrue(ConnectionGuard.shouldOpenNewSocket(hasSocket = false, sameUrl = true, socketActive = false))
// 4) during the reconnect window, item 1 still not ready (downloads deferred, FIX 1) ->
// advancing still finds item 0 -> screen still shows content
assertEquals(0, PlaylistSelection.nextPlayableIndex(2, 0, onlyZeroReady))
// 5) reconnected + item 1 now fully downloaded -> swaps forward to item 1 (only ever swap
// to fully-valid content). At no stage did selection return -1 while 0 was ready, so the
// screen was never blanked.
assertEquals(1, PlaylistSelection.nextPlayableIndex(2, 0, bothReady))
}
@Test fun `ack-gap window — reconnecting-not-yet-re-registered does not false-fire`() {
// openSocket resets armed=false + lastServerMessageAt=now. Before the first post-reconnect
// ack, armed=false so NO fire even though the OLD connection's silence was large...
assertFalse("must not fire before the first post-reconnect ack",
LivenessWatchdog.isHalfOpen(armed = false, connected = true, silenceMs = 999_999, thresholdMs = 45_000))
// ...and once any-inbound (the re-register response) refreshes liveness and an ack arms it,
// a fresh connection with small silence is healthy (still no fire).
assertFalse("healthy post-reconnect (armed, low silence) does not fire",
LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 500, thresholdMs = 45_000))
}
}

View file

@ -0,0 +1,76 @@
package com.remotedisplay.player.service
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* v4 liveness-contract conformance for the pure watchdog decision logic. Proves:
* - degrade-safe (never fires unarmed) and the connected+silent half-open condition,
* - the anti-thundering-herd guarantees: threshold jitter (45s ± up to 10s) and exponential
* reconnect backoff with jitter (1,2,4,8,16 capped, ±20%) are PRESENT (not fixed values).
*/
class LivenessWatchdogTest {
// ---- degrade-safe + half-open decision ----
@Test fun `unarmed never fires — degrade-safe against an ack-less server`() {
// Even a connected socket silent well past threshold must NOT reconnect until armed by an ack.
assertFalse(LivenessWatchdog.isHalfOpen(armed = false, connected = true, silenceMs = 999_999, thresholdMs = 45_000))
}
@Test fun `disconnected never fires — that is Socket_IO's job, not the watchdog's`() {
assertFalse(LivenessWatchdog.isHalfOpen(armed = true, connected = false, silenceMs = 999_999, thresholdMs = 45_000))
}
@Test fun `armed + connected + silent past threshold IS half-open`() {
assertTrue(LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 46_000, thresholdMs = 45_000))
}
@Test fun `armed + connected but within threshold is healthy`() {
assertFalse(LivenessWatchdog.isHalfOpen(armed = true, connected = true, silenceMs = 30_000, thresholdMs = 45_000))
}
// ---- anti-herd: threshold jitter 45s ± up to 10s ----
@Test fun `threshold is centered on 45s and stays within ±10s`() {
assertEquals(45_000L, LivenessWatchdog.thresholdMs(0.5))
assertEquals(35_000L, LivenessWatchdog.thresholdMs(0.0))
assertEquals(54_999L, LivenessWatchdog.thresholdMs(0.99995)) // ~55s upper bound (exclusive)
for (i in 0..100) {
val t = LivenessWatchdog.thresholdMs(i / 100.0)
assertTrue("threshold $t out of [35s,55s)", t in 35_000..55_000)
}
}
@Test fun `threshold has jitter — different rands give different thresholds (not fixed)`() {
assertTrue(LivenessWatchdog.thresholdMs(0.1) != LivenessWatchdog.thresholdMs(0.9))
}
// ---- anti-herd: exponential backoff 1,2,4,8,16… capped, ±20% jitter ----
@Test fun `backoff doubles per attempt then saturates at the cap`() {
// rand=0.5 => no jitter, so we see the exact base curve.
assertEquals(1_000L, LivenessWatchdog.backoffMs(1, 0.5))
assertEquals(2_000L, LivenessWatchdog.backoffMs(2, 0.5))
assertEquals(4_000L, LivenessWatchdog.backoffMs(3, 0.5))
assertEquals(8_000L, LivenessWatchdog.backoffMs(4, 0.5))
assertEquals(16_000L, LivenessWatchdog.backoffMs(5, 0.5))
assertEquals(30_000L, LivenessWatchdog.backoffMs(6, 0.5)) // 32s -> capped at 30s
assertEquals(30_000L, LivenessWatchdog.backoffMs(50, 0.5)) // no overflow at large attempts
}
@Test fun `backoff jitter is ±20% of the step and present (not fixed)`() {
// attempt 5 base = 16s; ±20% => [12.8s, 19.2s].
assertEquals(12_800L, LivenessWatchdog.backoffMs(5, 0.0)) // -20%
assertEquals(19_200L, LivenessWatchdog.backoffMs(5, 1.0)) // +20%
assertTrue(LivenessWatchdog.backoffMs(5, 0.2) != LivenessWatchdog.backoffMs(5, 0.8))
}
@Test fun `mayReconnectNow gates on the backoff having elapsed`() {
assertTrue(LivenessWatchdog.mayReconnectNow(msSinceLastAttempt = 2_000, backoffMs = 1_000))
assertTrue(LivenessWatchdog.mayReconnectNow(msSinceLastAttempt = 1_000, backoffMs = 1_000))
assertFalse(LivenessWatchdog.mayReconnectNow(msSinceLastAttempt = 500, backoffMs = 1_000))
}
}

View file

@ -263,6 +263,9 @@ body {
.status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.offline { background: var(--danger); }
.status-dot.provisioning { background: var(--warning); animation: pulse 2s infinite; }
/* v4 liveness (server-derived): healthy=green, degraded=amber+pulse (reconnecting), offline=red (above) */
.status-dot.healthy { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.degraded { background: var(--warning); box-shadow: 0 0 6px var(--warning); animation: pulse 2s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
@ -418,6 +421,19 @@ body {
font-size: 11px;
font-weight: 500;
}
/* Device cards render the reused liveness pill (.device-status-badge from device-detail), which brings
its own bg/padding/shape so neutralize the dark wrapper for those. Wall cards keep the dark pill
above for their "NxN wall" label (they share .device-card-status but have no .is-liveness). */
.device-card-status.is-liveness {
background: none;
backdrop-filter: none;
padding: 0;
border-radius: 0;
}
/* Lift the list pill off the screenshot so it stays legible over any image. */
.device-card-status.is-liveness .device-status-badge {
box-shadow: 0 1px 4px rgba(0,0,0,0.6);
}
.device-card-select {
position: absolute;
@ -803,6 +819,9 @@ body {
.device-status-badge.online { background: var(--success-dim); color: var(--success); }
.device-status-badge.offline { background: var(--danger-dim); color: #fca5a5; }
.device-status-badge.provisioning { background: var(--warning-dim); color: var(--warning); }
/* v4 liveness (server-derived): healthy=green, degraded=amber (reconnecting), offline reuses .offline above */
.device-status-badge.healthy { background: var(--success-dim); color: var(--success); }
.device-status-badge.degraded { background: var(--warning-dim); color: var(--warning); }
.tabs {
display: flex;

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#111827">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/assets/icon-192.png">

View file

@ -36,6 +36,10 @@ export const api = {
// no restart. Server enforces via the SNAT-safe identity chain (deviceSocket).
blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }),
unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }),
// #150: fingerprint-keyed settings snapshots of previously-removed devices (this workspace),
// and the re-adopt action that applies a snapshot onto a newly-paired device.
getRemovedDevices: () => request('/devices/removed'),
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).

View file

@ -264,6 +264,26 @@ export default {
'device.playlist.empty_desc': "Add content from your library to this display's playlist.",
'device.playlist_picker.with_count': '{name} — {n} items',
'device.playlist_picker.with_auto': '{name} (auto) — {n} items',
// v4 liveness badge (server-derived 3-state)
'device.liveness.healthy': 'Healthy',
'device.liveness.degraded': 'Reconnecting',
'device.liveness.offline': 'Offline',
// exit-signal contract — manner-of-death annotation on Offline (reliability-aware, §10)
'device.exit.crashed': 'crashed',
'device.exit.clean': 'clean exit',
'device.exit.clean_besteffort': 'clean exit (best-effort)',
'device.exit.silent': 'silent (no signal)',
'device.exit.silent_short': 'silent',
// filter drill-in options (sub-dimension of Offline)
'dashboard.filter.offline_silent': 'Offline · silent',
'dashboard.filter.offline_crashed': 'Offline · crashed',
'dashboard.filter.offline_clean': 'Offline · clean exit',
'dashboard.filter.offline_by_reason': 'Offline by reason',
// honest hover explanations (carry the contract's reliability)
'device.exit.crashed.tip': 'The apps own error handler fired — it crashed before dying (our fault).',
'device.exit.clean.tip': 'An orderly shutdown signal fired — something closed the app cleanly.',
'device.exit.clean_besteffort.tip': 'Reported an orderly shutdown, but this platforms signal is best-effort — likely, not certain.',
'device.exit.silent.tip': 'No exit signal arrived — external or violent termination: power loss, network drop, force-stop, or MDM/kill. The honest catch-all.',
// Info cards
'device.info.status': 'Status',
'device.info.ip_address': 'IP Address',
@ -301,6 +321,26 @@ export default {
'device.debug.toggle': 'Debug logging (live)',
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
'device.form.save_settings': 'Save Settings',
// #150 re-adopt: restore a removed device's saved settings onto this one
'device.readopt.button': 'Restore from removed device…',
'device.readopt.button_hint': "Apply a previously-removed device's saved settings onto this one (for a re-paired screen whose fingerprint changed)",
'device.readopt.title': 'Restore settings from a removed device',
'device.readopt.help': 'Pick a previously-removed device to copy its saved settings onto “{name}”. This overwrites the current settings.',
'device.readopt.empty': 'No previously-removed devices in this workspace.',
'device.readopt.unnamed': 'Unnamed device',
'device.readopt.blocked': 'Blocked',
'device.readopt.summary_orientation': 'Orientation',
'device.readopt.summary_timezone': 'Timezone',
'device.readopt.summary_playlist': 'Playlist',
'device.readopt.playlist_none': 'none',
'device.readopt.playlist_removed': '(playlist since deleted)',
'device.readopt.last_seen': 'Last seen',
'device.readopt.removed': 'Removed',
'device.readopt.apply': 'Apply',
'device.readopt.confirm': 'Apply saved settings from “{source}” onto “{target}”? This overwrites the current settings.',
'device.readopt.confirm_blocked': 'This device was BLOCKED. Applying it will re-block the target device — it will immediately refuse to connect and the screen will go dark. Continue?',
'device.readopt.success': 'Settings restored ({orientation})',
'device.readopt.error': 'Could not restore settings',
// Control buttons
'device.ctl.reboot_device': 'Reboot Device',
'device.ctl.screen_off': 'Screen Off',

View file

@ -1,9 +1,68 @@
import { t } from './i18n.js';
// HTML escape helper — prevents XSS when inserting user data into innerHTML
export function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// v4 liveness badge. The patch4 server derives a 3-state liveness — 'healthy' / 'degraded'
// (temporarily reconnecting) / 'offline' — and emits it as `data.liveness` on dashboard:device-status.
// It is present on SOME emits only (the plain reconnect + disconnect emits, and any device object read
// from the DB, carry just the binary `status`), so we DEGRADE to the binary status when liveness is
// absent — nothing ever renders blank. 'provisioning' is a lifecycle state (never-paired), kept
// distinct from liveness. livenessState() is pure (unit-testable); livenessBadge() adds the i18n label.
const LIVENESS_LABEL_KEY = {
healthy: 'device.liveness.healthy',
degraded: 'device.liveness.degraded',
offline: 'device.liveness.offline',
provisioning: 'dashboard.awaiting_pairing',
};
export function livenessState(data) {
const lv = data && data.liveness;
if (lv === 'healthy' || lv === 'degraded' || lv === 'offline') return lv; // 3-state signal present
const st = data && data.status; // backward-compat: derive from binary status
if (st === 'provisioning') return 'provisioning';
if (st === 'online') return 'healthy';
if (st === 'offline') return 'offline';
return 'offline'; // unknown / no data yet -> safe default, never blank
}
// Exit-signal contract §8/§10 — honest, reliability-aware manner-of-death sub-label for an Offline
// device. clean_exit is RELIABLE only on the browser /player (pagehide+sendBeacon); best-effort on
// APK/.wgt, so we qualify it there rather than overstate certainty. crashed/silent are labeled plainly.
// Returns null when no reason is known (old data / never went offline) -> plain "Offline".
// short=true -> concise LIST label (drops the parenthetical qualifiers, which the tooltip still carries);
// full (default) -> DETAIL label with the reliability qualifier. Honesty is preserved either way: the
// full meaning lives in the tooltip (both views) and in the detail label.
function offlineReasonLabel(reason, clientType, short) {
if (reason === 'crashed') return t('device.exit.crashed');
if (reason === 'clean_exit') {
if (short) return t('device.exit.clean'); // list: "clean exit" (tooltip carries best-effort)
return clientType === 'player' ? t('device.exit.clean') : t('device.exit.clean_besteffort');
}
if (reason === 'silent') return short ? t('device.exit.silent_short') : t('device.exit.silent');
return null;
}
// Honest hover explanation of the manner of death — carries the contract's reliability (esp. 'silent'
// = external/violent, and best-effort clean_exit) so an operator isn't misled by a terse badge label.
function offlineReasonTip(reason, clientType) {
if (reason === 'crashed') return t('device.exit.crashed.tip');
if (reason === 'clean_exit') return clientType === 'player' ? t('device.exit.clean.tip') : t('device.exit.clean_besteffort.tip');
if (reason === 'silent') return t('device.exit.silent.tip');
return '';
}
export function livenessBadge(data, opts = {}) {
const state = livenessState(data);
let label = t(LIVENESS_LABEL_KEY[state]);
let title = '', reason = '';
if (state === 'offline') { // annotate Offline with the manner of death, if known
const r = data && data.offline_reason, ct = data && data.client_type;
const sub = offlineReasonLabel(r, ct, opts.short);
if (sub) { label += ' · ' + sub; title = offlineReasonTip(r, ct); reason = r || ''; }
}
return { state, label, title, reason }; // reason -> data-offline-reason (filter drill-in); '' unless offline+known
}
// Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin'
// role to 'platform_admin'. Existing frontend checks still match the old
// string; this helper accepts both so we don't have to splatter the array

View file

@ -1,7 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { esc, livenessBadge } from '../utils.js';
import { t, tn } from '../i18n.js';
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
@ -100,9 +100,8 @@ function renderDeviceCard(device) {
<span>${t('dashboard.no_preview')}</span>
</div>`
}
<div class="device-card-status">
<span class="status-dot ${device.status}"></span>
<span>${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status}</span>
<div class="device-card-status is-liveness">
${(() => { const b = livenessBadge(device, { short: true }); return `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`; })()}
</div>
${device.status === 'provisioning' && device.pairing_code ? `
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
@ -269,10 +268,16 @@ export function render(container) {
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
<select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)">
<select id="deviceFilter" class="input" style="width:180px;background:var(--bg-input)">
<option value="">${t('dashboard.all_status')}</option>
<option value="online">${t('dashboard.online')}</option>
<option value="offline">${t('dashboard.offline')}</option>
<option value="healthy">${t('device.liveness.healthy')}</option>
<option value="degraded">${t('device.liveness.degraded')}</option>
<option value="offline">${t('device.liveness.offline')}</option>
<optgroup label="${t('dashboard.filter.offline_by_reason')}">
<option value="offline:silent">${t('dashboard.filter.offline_silent')}</option>
<option value="offline:crashed">${t('dashboard.filter.offline_crashed')}</option>
<option value="offline:clean_exit">${t('dashboard.filter.offline_clean')}</option>
</optgroup>
</select>
</div>
<div id="groupedDevices"></div>
@ -292,13 +297,21 @@ export function render(container) {
function filterDevices() {
const search = document.getElementById('deviceSearch').value.toLowerCase();
const status = document.getElementById('deviceFilter').value;
// Compare against the liveness STATE ('healthy'|'degraded'|'offline'), NOT the display label:
// the badge text is now "Healthy"/"Reconnecting"/"Offline", so the old text-vs-'online' compare
// matched nothing and emptied the list. data-liveness carries the state for a robust match.
const filter = document.getElementById('deviceFilter').value; // '' | healthy | degraded | offline | offline:<reason>
const reasonDrill = filter.startsWith('offline:') ? filter.slice(8) : null; // drill into a manner-of-death
document.querySelectorAll('.device-card').forEach(card => {
const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || '';
const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || '';
const el = card.querySelector('.device-card-status [data-liveness]');
const cardState = el?.dataset.liveness || '';
const cardReason = el?.dataset.offlineReason || '';
const matchSearch = !search || name.includes(search);
const matchStatus = !status || deviceStatus === status;
card.style.display = (matchSearch && matchStatus) ? '' : 'none';
const matchState = reasonDrill
? (cardState === 'offline' && cardReason === reasonDrill) // Offline drill-in: liveness AND reason (e.g. silent = MDM-killed set)
: (!filter || cardState === filter); // existing three-state filter — unchanged
card.style.display = (matchSearch && matchState) ? '' : 'none';
});
}
@ -359,10 +372,11 @@ export function render(container) {
// Real-time updates
statusHandler = (data) => {
const b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text
const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`);
cards.forEach(card => {
const statusEl = card.querySelector('.device-card-status');
if (statusEl) statusEl.innerHTML = `<span class="status-dot ${data.status}"></span><span>${data.status}</span>`;
if (statusEl) statusEl.innerHTML = `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`;
});
};

View file

@ -1,7 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { esc, livenessBadge } from '../utils.js';
import { t, tn } from '../i18n.js';
let currentDevice = null;
@ -68,8 +68,10 @@ export function render(container, deviceId) {
if (data.device_id !== deviceId) return;
const badge = document.querySelector('.device-status-badge');
if (badge) {
badge.className = `device-status-badge ${data.status}`;
badge.textContent = data.status;
const b = livenessBadge(data); // v4: 3-state liveness when present, else binary status
badge.className = `device-status-badge ${b.state}`;
badge.textContent = b.label;
badge.title = b.title || ''; // exit-reason hover (empty for non-offline / no-reason)
}
if (data.telemetry) updateTelemetryDisplay(data.telemetry);
};
@ -149,7 +151,7 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="device-header">
<div class="device-header-left">
<h1 id="deviceName">${device.name}</h1>
<span class="device-status-badge ${device.status}">${device.status}</span>
${(() => { const b = livenessBadge(device); return `<span class="device-status-badge ${b.state}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`; })()}
${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''}
</div>
<div style="display:flex;gap:8px">
@ -370,6 +372,7 @@ async function loadDevice(deviceId, activeTab = null) {
<textarea id="deviceNotes" class="input" rows="3" placeholder="${t('device.form.notes_placeholder')}" style="resize:vertical">${esc(device.notes || '')}</textarea>
</div>
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">${t('device.form.save_settings')}</button>
<button class="btn btn-secondary btn-sm" id="reAdoptBtn" style="margin-left:8px" title="${t('device.readopt.button_hint')}">${t('device.readopt.button')}</button>
</div>
<div style="margin-top:20px">
@ -644,7 +647,100 @@ function showDevicePreview(device) {
});
}
async function setupActions(device) {
// #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and
// apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent
// fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint.
const ORIENT_LABELS = {
'landscape': 'device.form.orientation.landscape',
'portrait': 'device.form.orientation.portrait',
'landscape-flipped': 'device.form.orientation.landscape_flipped',
'portrait-flipped': 'device.form.orientation.portrait_flipped',
};
const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape);
const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—');
async function showReAdoptModal(device) {
let snapshots, playlists;
try {
[snapshots, playlists] = await Promise.all([
api.getRemovedDevices(),
api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist
]);
} catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; }
const plById = new Map((playlists || []).map(p => [p.id, p.name]));
const playlistLabel = (s) => !s.playlist_id
? t('device.readopt.playlist_none')
: (plById.get(s.playlist_id) || t('device.readopt.playlist_removed'));
const rowsHtml = (snapshots || []).map((s, i) => {
const blockedBadge = s.blocked
? `<span style="background:var(--danger,#dc2626);color:#fff;padding:1px 7px;border-radius:4px;font-size:11px;margin-left:8px;vertical-align:middle">${t('device.readopt.blocked')}</span>`
: '';
// Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only.
const fpShort = (s.fingerprint || '').slice(0, 8);
return `
<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:12px">
<div style="flex:1;min-width:0">
<div style="font-weight:600">${esc(s.device_name || t('device.readopt.unnamed'))}${blockedBadge}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:3px">
${t('device.readopt.summary_orientation')}: ${esc(orientLabel(s.orientation))}
&nbsp;·&nbsp; ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')}
&nbsp;·&nbsp; ${t('device.readopt.summary_playlist')}: ${esc(playlistLabel(s))}
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:3px" title="fp ${esc(fpShort)}…">
${t('device.readopt.last_seen')}: ${esc(fmtTs(s.last_seen))} &nbsp;·&nbsp; ${t('device.readopt.removed')}: ${esc(fmtTs(s.removed_at))}
</div>
</div>
<button class="btn btn-primary btn-sm readopt-apply" data-i="${i}">${t('device.readopt.apply')}</button>
</div>`;
}).join('');
const emptyHtml = `<div style="text-align:center;color:var(--text-muted);padding:36px 12px">${t('device.readopt.empty')}</div>`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal" style="max-width:600px;width:95vw">
<div class="modal-header">
<h3>${t('device.readopt.title')}</h3>
<button class="btn-icon" id="readoptClose">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<p style="color:var(--text-muted);font-size:13px;margin-top:0">${t('device.readopt.help', { name: esc(device.name || '') })}</p>
${(snapshots && snapshots.length) ? rowsHtml : emptyHtml}
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('#readoptClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
overlay.querySelectorAll('.readopt-apply').forEach((btn) => {
btn.addEventListener('click', async () => {
const s = snapshots[parseInt(btn.dataset.i, 10)];
let msg = t('device.readopt.confirm', { source: s.device_name || t('device.readopt.unnamed'), target: device.name || '' });
if (s.blocked) msg += '\n\n⚠ ' + t('device.readopt.confirm_blocked'); // explicit: target will go dark
if (!confirm(msg)) return;
btn.disabled = true;
try {
await api.reAdoptDevice(device.id, s.fingerprint);
showToast(t('device.readopt.success', { orientation: orientLabel(s.orientation) }), 'success');
close();
loadDevice(device.id); // refresh so restored orientation/name/etc show immediately
} catch (err) {
// Server messages: 404 no snapshot, 403 cross-workspace, 400 bad request.
showToast(err.message || t('device.readopt.error'), 'error');
btn.disabled = false;
}
});
});
}
function setupActions(device) {
// #104 Preview button
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
@ -669,9 +765,11 @@ async function setupActions(device) {
}
});
// Populate default content dropdown
try {
const content = await api.getContent();
// Populate default content dropdown (async, non-blocking — same .then() pattern as the
// playlist picker below). setupActions is a SYNCHRONOUS function; awaiting here made the whole
// file fail to parse ("Unexpected reserved word") AND would have deferred every listener below
// (save, #150 re-adopt, delete) until this fetch resolved. .then() keeps them registering immediately.
api.getContent().then(content => {
const defaultSelect = document.getElementById('deviceDefaultContent');
if (defaultSelect) {
content.forEach(c => {
@ -681,7 +779,7 @@ async function setupActions(device) {
defaultSelect.appendChild(opt);
});
}
} catch {}
}).catch(() => {});
// Save settings (notes + orientation + default content)
// Debug logging toggle: sends a transient set_debug command to the device and
@ -706,6 +804,10 @@ async function setupActions(device) {
}
});
// #150 re-adopt: apply a previously-removed device's saved settings onto THIS device (the
// fallback for when the fingerprint changed and automatic restore couldn't fire).
document.getElementById('reAdoptBtn')?.addEventListener('click', () => showReAdoptModal(device));
// Publish / Discard from device detail
const devicePublishBtn = document.getElementById('devicePublishBtn');
if (devicePublishBtn && device.playlist_id) {

View file

@ -105,7 +105,7 @@ function openContentPicker({ multiple = false, title } = {}) {
});
}
function showPreviewModal(html, widgetType) {
function showPreviewModal(sessionId, widgetType) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
// #104: webpage widgets pointing at frame-denying sites (X-Frame-Options) can't be
@ -124,12 +124,7 @@ function showPreviewModal(html, widgetType) {
${webpageNote}
</div>`;
document.body.appendChild(overlay);
// srcdoc resolves relative URLs against about:srcdoc, so inject <base> pointing to our origin
const baseTag = `<base href="${window.location.origin}/">`;
const withBase = /<head[^>]*>/i.test(html)
? html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`)
: html.replace(/<html([^>]*)>/i, `<html$1><head>${baseTag}</head>`);
overlay.querySelector('#pvIframe').srcdoc = withBase;
overlay.querySelector('#pvIframe').src = '/api/widgets/preview-session/' + sessionId;
const close = () => overlay.remove();
overlay.querySelector('#pvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
@ -551,14 +546,14 @@ export async function render(container) {
if (!type) return;
const config = getConfigFromForm(type);
try {
const res = await fetch('/api/widgets/preview', {
const res = await fetch('/api/widgets/preview-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ widget_type: type, config }),
});
if (!res.ok) throw new Error(t('widget.toast.preview_failed'));
const html = await res.text();
showPreviewModal(html, type);
const { id } = await res.json();
showPreviewModal(id, type);
} catch (err) { showToast(err.message, 'error'); }
};

View file

@ -88,6 +88,19 @@ const migrations = [
'ALTER TABLE content ADD COLUMN team_id TEXT',
// Device notes
'ALTER TABLE devices ADD COLUMN notes TEXT',
// v4 core pass — client identity capture (capture-don't-act; degrades to legacy/unknown for old
// pre-v4 clients that send no identity block). No logic is built on these yet.
'ALTER TABLE devices ADD COLUMN client_type TEXT',
'ALTER TABLE devices ADD COLUMN client_version TEXT',
'ALTER TABLE devices ADD COLUMN platform TEXT',
'ALTER TABLE devices ADD COLUMN contract_version TEXT',
// Exit-signal contract v1 — manner-of-death annotation on Offline (additive; NEVER alters offline
// detection). offline_reason: 'crashed'|'clean_exit' (client-sent via device:exit / beacon) or
// 'silent' (server-inferred when no signal arrived). Cleared on (re)online so it's always this
// session's. offline_detail: optional crash message / lifecycle-hook name.
'ALTER TABLE devices ADD COLUMN offline_reason TEXT',
'ALTER TABLE devices ADD COLUMN offline_reason_at INTEGER',
'ALTER TABLE devices ADD COLUMN offline_detail TEXT',
// Email settings on users
"ALTER TABLE users ADD COLUMN email_alerts INTEGER DEFAULT 1",
// Content folders
@ -254,6 +267,26 @@ const migrations = [
// settings_pin: 6-digit PIN for the in-app hidden settings menu, provisioned by
// the server during pairing so each device gets a unique PIN (never a hardcoded default).
"ALTER TABLE devices ADD COLUMN settings_pin TEXT",
// #150: fingerprint-keyed device settings that SURVIVE device-row deletion, so a
// delete + re-pair (MDM churn) restores orientation/name/playlist/etc for the SAME
// physical device instead of silently resetting to defaults. NO FK to devices -> it
// survives the delete cascade. workspace_id/device_name/last_seen/removed_at form the
// human-readable index the operator "re-adopt" flow browses when the fingerprint changed.
`CREATE TABLE IF NOT EXISTS device_settings (
fingerprint TEXT PRIMARY KEY,
workspace_id TEXT,
device_name TEXT,
orientation TEXT,
timezone TEXT,
notes TEXT,
default_content_id TEXT,
layout_id TEXT,
playlist_id TEXT,
blocked INTEGER,
team_id TEXT,
last_seen INTEGER,
removed_at INTEGER
)`,
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.

View file

@ -0,0 +1,124 @@
'use strict';
// #150 — fingerprint-keyed device settings that survive device-row deletion.
//
// Delete + re-pair (Bold's MDM churn) mints a BRAND-NEW device row whose INSERT omits every
// per-device setting, so orientation/name/playlist/etc silently reset to defaults. This module
// snapshots a device's settings (keyed by its durable hardware/canvas fingerprint) at DELETE
// time, and re-applies them on the next re-pair for the SAME fingerprint — automatically and
// silently. The same apply path also backs the operator "re-adopt" action for the case where
// the fingerprint changed (factory reset / new hardware), see routes/devices.js.
//
// The table has NO FK to devices, so device deletion can't cascade it away. On workspace/user/
// org deletion the rows ARE purged (purgeWorkspaces) so settings can never bleed across tenants.
const { db } = require('../db/database');
const ORIENTATIONS = new Set(['landscape', 'portrait', 'landscape-flipped', 'portrait-flipped']);
const validOrientation = (o) => (ORIENTATIONS.has(o) ? o : 'landscape');
// The devices-row columns we preserve/restore (approved scope: orientation, name, timezone,
// notes, default_content_id, layout_id, playlist_id, blocked, team_id). sort_order is out of
// scope by decision; wall membership (video_wall_devices grid geometry) is a deferred follow-up.
const _selDevice = db.prepare(
`SELECT name, orientation, timezone, notes, default_content_id, layout_id, playlist_id,
blocked, team_id, workspace_id, last_heartbeat
FROM devices WHERE id = ?`
);
const _fpForDevice = db.prepare(
'SELECT fingerprint FROM device_fingerprints WHERE device_id = ? ORDER BY last_seen DESC LIMIT 1'
);
const _upsert = db.prepare(`
INSERT INTO device_settings
(fingerprint, workspace_id, device_name, orientation, timezone, notes, default_content_id,
layout_id, playlist_id, blocked, team_id, last_seen, removed_at)
VALUES
(@fingerprint, @workspace_id, @device_name, @orientation, @timezone, @notes, @default_content_id,
@layout_id, @playlist_id, @blocked, @team_id, @last_seen, @removed_at)
ON CONFLICT(fingerprint) DO UPDATE SET
workspace_id=excluded.workspace_id, device_name=excluded.device_name, orientation=excluded.orientation,
timezone=excluded.timezone, notes=excluded.notes, default_content_id=excluded.default_content_id,
layout_id=excluded.layout_id, playlist_id=excluded.playlist_id, blocked=excluded.blocked,
team_id=excluded.team_id, last_seen=excluded.last_seen, removed_at=excluded.removed_at
`);
// Snapshot a device's current settings keyed by its fingerprint, called BEFORE the row is
// deleted. No-op (returns null) if the device has no fingerprint link yet — a never-fully-
// provisioned device has no durable key and no user settings worth preserving. UPSERT keyed on
// fingerprint => repeated delete/re-pair cycles update one row, never duplicate.
function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) {
const d = _selDevice.get(deviceId);
if (!d) return null;
const fpRow = _fpForDevice.get(deviceId);
if (!fpRow || !fpRow.fingerprint) return null;
_upsert.run({
fingerprint: fpRow.fingerprint,
workspace_id: d.workspace_id || null,
device_name: d.name || null,
orientation: validOrientation(d.orientation),
timezone: d.timezone || null,
notes: d.notes || null,
default_content_id: d.default_content_id || null,
layout_id: d.layout_id || null,
playlist_id: d.playlist_id || null,
blocked: d.blocked ? 1 : 0,
team_id: d.team_id || null,
last_seen: d.last_heartbeat || now,
removed_at: now,
});
return fpRow.fingerprint;
}
// Apply saved settings for `fingerprint` onto `deviceId`. Backs BOTH the automatic re-pair
// restore and the operator re-adopt. Orientation is enum-validated (invalid stored value ->
// landscape). FK settings (playlist/layout/default_content) are existence-guarded so a
// since-deleted target is skipped rather than written as a dangling id. Returns the applied
// snapshot row, or null if there was nothing to apply.
function applyToDevice(deviceId, fingerprint) {
const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
if (!s) return null;
const sets = [], vals = [];
const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); };
put('orientation', validOrientation(s.orientation));
if (s.device_name != null) put('name', s.device_name);
if (s.timezone != null) put('timezone', s.timezone);
if (s.notes != null) put('notes', s.notes);
put('blocked', s.blocked ? 1 : 0); // security: a blocked device stays blocked across re-pair
if (s.team_id != null) put('team_id', s.team_id);
// FK-existence guards — only restore if the referenced row still exists.
if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id)) put('playlist_id', s.playlist_id);
if (s.layout_id && db.prepare('SELECT 1 FROM layouts WHERE id = ?').get(s.layout_id)) put('layout_id', s.layout_id);
if (s.default_content_id && db.prepare('SELECT 1 FROM content WHERE id = ?').get(s.default_content_id)) put('default_content_id', s.default_content_id);
// TODO #150 follow-up: wall membership (video_wall_devices grid geometry) is NOT restored —
// it lives in a separate CASCADE-deleted table with grid positions. Deferred; note in release.
vals.push(deviceId);
db.prepare(`UPDATE devices SET ${sets.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...vals);
return s;
}
// The "previously removed devices" browser — snapshots for the given workspace(s).
function listRemoved(workspaceIds) {
const ids = (Array.isArray(workspaceIds) ? workspaceIds : [workspaceIds]).filter(Boolean);
if (!ids.length) return [];
const ph = ids.map(() => '?').join(',');
return db.prepare(
`SELECT fingerprint, workspace_id, device_name, orientation, playlist_id, layout_id,
timezone, blocked, last_seen, removed_at
FROM device_settings WHERE workspace_id IN (${ph}) ORDER BY removed_at DESC`
).all(...ids);
}
function getByFingerprint(fingerprint) {
return db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
}
// Purge snapshots for whole workspaces (workspace/user/org deletion). Runs on the caller's
// db handle (user-deletion runs inside a transaction). Prevents cross-tenant settings bleed.
function purgeWorkspaces(dbConn, workspaceIds) {
const ids = (workspaceIds || []).filter(Boolean);
if (!ids.length) return 0;
const ph = ids.map(() => '?').join(',');
return (dbConn || db).prepare(`DELETE FROM device_settings WHERE workspace_id IN (${ph})`).run(...ids).changes;
}
module.exports = { snapshot, applyToDevice, listRemoved, getByFingerprint, purgeWorkspaces, validOrientation, ORIENTATIONS };

81
server/lib/liveness.js Normal file
View file

@ -0,0 +1,81 @@
'use strict';
// v4 CORE-PASS liveness helpers — pure, VERSION-AGNOSTIC, mixed-fleet-safe. Dependency-free so they
// are unit-testable and the imperative shells (deviceSocket heartbeat/register handlers, the
// heartbeat offline sweep) stay thin. The server talks to a MIX simultaneously — v4 clients (have a
// watchdog, consume the ack, send an identity block), OLD pre-v4 clients (none of that), and
// genuinely-disconnected devices — and none of these may break the server or each other.
// ── Uniform ack (PRIMARY + FIX 1: reconnect-window gap) ────────────────────────────────────────
// Should THIS device:heartbeat be acked with device:heartbeat-ack? The ack keeps a v4 client's
// watchdog armed; it is emitted from the SHARED heartbeat handler (uniform by construction across
// APK / .wgt / /player) and is HARMLESS to old clients (they don't consume it). We ack a KNOWN
// device — identity-agnostic:
// - an already-authenticated socket (authedDeviceId set), OR
// - a heartbeat carrying a device_id that RESOLVES to a real device (a real device mid-reconnect,
// BEFORE this socket finished re-registering — the deferred ack-gap fix).
// We do NOT ack anonymous / never-authenticated sockets (no device_id, or an unknown id): those are
// covered by degrade-safe — an un-acked client's watchdog simply never arms, so there is no
// false-fire and no storm.
function ackableHeartbeat(authedDeviceId, heartbeatDeviceId, deviceExists) {
if (authedDeviceId) return true; // authenticated socket -> known
if (!heartbeatDeviceId) return false; // anonymous heartbeat -> not acked
return !!deviceExists(heartbeatDeviceId); // real device mid-reconnect -> ack (window fix)
}
// ── Dashboard liveness (FIX 2: server-derived, VERSION-AGNOSTIC 3-state) ────────────────────────
// Derived ONLY from signals EVERY client sends — socket presence, last-heartbeat age, reconnect
// frequency — never from v4-only signals. Correct for v4 clients, OLD clients (connected +
// heartbeating -> healthy), and disconnected clients (-> offline, a normal state, NOT an error).
// offline : no live socket.
// degraded : connected but reconnecting frequently (churn), OR connected but silent past the window.
// healthy : connected + a recent heartbeat + not churning.
const HEALTHY_HEARTBEAT_MS = 35000; // 2× the 15s client heartbeat + margin
const DEGRADED_RECONNECTS = 3; // >=3 (re)registers within the reconnect window => churn
function deriveLiveness({ connected, lastHeartbeatAgeMs, recentReconnects } = {}, opts = {}) {
const hbMax = opts.healthyHeartbeatMs != null ? opts.healthyHeartbeatMs : HEALTHY_HEARTBEAT_MS;
const churn = opts.degradedReconnects != null ? opts.degradedReconnects : DEGRADED_RECONNECTS;
if (!connected) return 'offline';
if ((recentReconnects || 0) >= churn) return 'degraded';
if ((lastHeartbeatAgeMs || 0) > hbMax) return 'degraded';
return 'healthy';
}
// ── Identity capture (FIX 3: capture-don't-act, DEGRADES on missing) ────────────────────────────
// Capture the v4 identity block when present; when absent/partial (an OLD client), fill
// "legacy"/"unknown" — NEVER fail on a missing field. No logic is built on this yet.
function captureIdentity(data) {
const d = data || {};
return {
client_type: d.client_type || 'legacy',
client_version: d.client_version || 'unknown',
platform: d.platform || 'unknown',
contract_version: d.contract_version || 'legacy',
};
}
// A1 change-detection: has the (already-captured) identity changed vs what's stored? A genuine
// reconnect with an unchanged identity (the common case) then does NO write. A never-stored device
// (current null / all-NULL columns) or a real change (e.g. new client_version after an OTA) writes.
function identityChanged(current, incoming) {
if (!current) return true;
return current.client_type !== incoming.client_type
|| current.client_version !== incoming.client_version
|| current.platform !== incoming.platform
|| current.contract_version !== incoming.contract_version;
}
// Exit-signal contract v1 — manner-of-death. A client may ONLY announce 'crashed' (its uncaught-
// exception handler fired) or 'clean_exit' (a confident lifecycle-end). 'silent' is server-inferred by
// ABSENCE and is NEVER accepted from a client. Honesty by construction: an unknown/uncertain value is
// rejected (-> null), so the device falls to server-inferred 'silent' rather than being coerced into a
// wrong category. detail is optional (crash message / lifecycle-hook name), sanitized + length-capped.
const CLIENT_EXIT_REASONS = ['crashed', 'clean_exit'];
function sanitizeExitReason(reason, detail) {
if (!CLIENT_EXIT_REASONS.includes(reason)) return null;
const d = (typeof detail === 'string' && detail.trim()) ? detail.trim().slice(0, 200) : null;
return { reason, detail: d };
}
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };

View file

@ -59,6 +59,13 @@ function cmpParsed(a, b) {
}
function cmp(a, b) { const pa = parseVer(a), pb = parseVer(b); return (!pa || !pb) ? null : cmpParsed(pa, pb); }
// A '-patchN' suffix (e.g. 1.9.2-patch3) was the LEGACY release scheme — a shipped PRODUCTION patch,
// not a prerelease. Semver parses it into `pre`, but for OTA it must count as RELEASED so the
// superseded-prerelease guard below doesn't strand the old fleet: a 1.9.2-patchN device must still be
// offered a newer stable core (1.9.3). Genuine prereleases (-beta/-rc/-alpha) keep prerelease
// semantics. (Clean semver going forward emits no -patchN, so this only matters for the transition.)
function isReleased(p) { return p.pre === null || /^patch\d+$/i.test(p.pre); }
// decide(clientVersion, latestVersion, deviceId?, now?) ->
// { update_available, reason, retry_after_seconds?, log? }
function decide(clientVersion, latestVersion, deviceId = null, now = Date.now()) {
@ -69,7 +76,7 @@ function decide(clientVersion, latestVersion, deviceId = null, now = Date.now())
const full = cmpParsed(pc, pl);
if (full === 0) return { update_available: false, reason: 'up-to-date' };
if (full > 0) return { update_available: false, reason: 'client-newer' }; // never offer a downgrade
if (pc.pre !== null && coreCmp(pc, pl) < 0) { // superseded old-core prerelease (e.g. 1.9.1-beta4)
if (!isReleased(pc) && coreCmp(pc, pl) < 0) { // GENUINE superseded old-core prerelease (e.g. 1.9.1-beta4) — a -patchN release is NOT one, so it still gets offered
return { update_available: false, reason: 'superseded-prerelease', log: logOnce(clientVersion, `[ota] superseded prerelease '${clientVersion}' (older core than latest=${latestVersion}) — no offer`) };
}

View file

@ -76,6 +76,11 @@ function purgeWorkspaces(db, wsIds, have) {
}
}
for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds);
// #150: purge fingerprint-keyed device settings for these workspaces. device_settings has
// NO FK to devices (so it survives device deletion by design), which means it is NOT caught
// by this cascade either — purge it explicitly so saved settings can never bleed onto a
// different tenant if the same physical device (same fingerprint) later pairs elsewhere.
if (have.has('device_settings')) db.prepare(`DELETE FROM device_settings WHERE workspace_id IN (${wph})`).run(...wsIds);
if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds);
db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites
}

View file

@ -1,12 +1,12 @@
{
"name": "screentinker",
"version": "1.9.2-patch3",
"version": "1.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "screentinker",
"version": "1.9.2-patch3",
"version": "1.9.3",
"dependencies": {
"@azure/msal-node": "^5.2.1",
"archiver": "^7.0.1",

View file

@ -1,6 +1,6 @@
{
"name": "screentinker",
"version": "1.9.2-patch3",
"version": "1.9.3",
"description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js",
"scripts": {

View file

@ -345,6 +345,44 @@
let config = getConfig();
let playlist = [];
let currentIndex = -1;
// ==================== Exit-signal contract v1 (best-effort last gasp) ====================
// Announce manner-of-death via navigator.sendBeacon (reliable-on-unload — survives the socket
// teardown). crashed: real uncaught SCRIPT error / unhandled rejection. clean_exit: pagehide with
// persisted=false (a genuine unload — NOT a bfcache suspend, which the liveness watchdog handles,
// and NOT mere visibility-hidden). Honesty: only these two confident categories are ever sent;
// anything uncertain sends nothing -> the server infers 'silent'. Idempotent (first signal wins,
// so a crash is never relabelled clean_exit by the pagehide that follows it).
let __exitSent = false;
function sendExitBeacon(reason, detail) {
try {
if (__exitSent) return;
if (reason !== 'crashed' && reason !== 'clean_exit') return;
if (!config || !config.deviceId || !config.deviceToken) return; // unpaired -> nothing to attribute
__exitSent = true;
const url = (config.serverUrl || window.location.origin) + '/api/device/exit';
const body = JSON.stringify({ device_id: config.deviceId, device_token: config.deviceToken,
reason, detail: (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined });
const blob = new Blob([body], { type: 'application/json' }); // Content-Type so express.json parses it
if (navigator.sendBeacon && navigator.sendBeacon(url, blob)) return;
fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => {});
} catch (e) { /* a dying page must never throw */ }
}
window.addEventListener('error', (ev) => {
// ONLY a real uncaught script error is a crash — a resource (img/script/link) load failure is NOT.
if (!ev) return;
const isResourceError = ev.target && ev.target !== window && (ev.target.src || ev.target.href);
if (isResourceError) return;
sendExitBeacon('crashed', (ev.error && ev.error.message) || ev.message || 'error');
});
window.addEventListener('unhandledrejection', (ev) => {
const r = ev && ev.reason;
sendExitBeacon('crashed', (r && (r.message || String(r))) || 'unhandledrejection');
});
window.addEventListener('pagehide', (ev) => {
if (ev && ev.persisted) return; // bfcache SUSPEND (may restore) — NOT a death; watchdog owns it
sendExitBeacon('clean_exit', 'pagehide');
});
let isPlaying = false;
let playerTimezone = null; // #74/#75: device-effective IANA tz for schedule eval
let scheduleRetryTimer = null; // re-check when every item is filtered out
@ -745,6 +783,65 @@
container.appendChild(note);
}
// ==================== v4 liveness watchdog ====================
// Brings /player onto the LOCKED v4 contract, IDENTICAL on the wire to the APK and .wgt.
// Threshold 45s ± up to 10s jitter (canonical); arm ONLY after a device:heartbeat-ack
// (degrade-safe — an ack-less server never arms us); lastServerMessageAt refreshes on ANY
// inbound (the SILENCE check) while ARMING gates on the ack. Backoff (1s→30s ±20%) is on the
// socket.io Manager (io opts below). NO status/health poll — load is read from ack-silence.
// Browser-specific half-open triggers (visibility/resume/online) drive the SAME check + the
// #148 teardown-first reconnect — additional triggers, not a separate mechanism.
const PLAYER_VERSION = '1.1.0-web';
const V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000;
function v4ThresholdMs(rand) { return V4_THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * V4_THRESHOLD_JITTER_MS); }
// Wall-clock (Date.now) so silence COUNTS sleep/background time — the browser half-open causes
// (a setInterval tick alone would be throttled/frozen in a hidden tab and undercount).
let lastServerMessageAt = 0;
let livenessConfirmed = false;
let livenessWindowMs = V4_THRESHOLD_BASE_MS;
let watchdogTimer = null;
function markAlive() { lastServerMessageAt = Date.now(); } // ANY inbound refreshes silence (does NOT arm)
// Pure decision (matches the APK/.wgt watchdogShouldReconnect): reconnect only a connected +
// registered socket whose liveness we've ARMED that has gone silent past the jittered window.
function watchdogShouldReconnect(hasSocket, connected, armed, silentMs, windowMs) {
return !!(hasSocket && connected && armed && silentMs > windowMs);
}
function checkLiveness() {
// THROTTLE-AWARE. (1) Silence is computed by TIMESTAMP (now - lastServerMessageAt), never by
// timer-fire-count, so a background-THROTTLED/late timer still measures the ACTUAL elapsed
// silence — it won't miss a real half-open for firing late, nor false-fire for firing late.
// (2) While the tab is HIDDEN, timers are throttled and the gap is EXPECTED — do NOT act on it
// (never reconnect a backgrounded tab). The hidden->visible transition resets the grace via
// verifyLivenessSoon(), so we don't false-fire on resume either.
if (document.visibilityState !== 'visible') return;
const silentMs = lastServerMessageAt ? (Date.now() - lastServerMessageAt) : 0;
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), livenessConfirmed, silentMs, livenessWindowMs)) {
console.log('[v4] half-open (silent ' + silentMs + 'ms > ' + livenessWindowMs + ') — teardown+reconnect');
connect(config.serverUrl); // #148 teardown-before-reopen: connect() disconnects the old socket first
}
}
// Fresh liveness check for a resume / bfcache-restore / network-change: silence accumulated
// while hidden or throttled is NOT trustworthy, so NEVER tear down a possibly-live socket on it.
// Reset the grace so the socket gets a fresh window to prove itself — a live socket's engine
// ping/ack refreshes silence within the window (no reconnect); a genuinely dead one stays silent
// past the window and the (now-visible) watchdog reconnects it. #148 teardown-first via connect().
function verifyLivenessSoon() {
lastServerMessageAt = Date.now(); // fresh grace — don't false-fire on stale hidden-silence
if (!socket) connect(config.serverUrl); // never connected / torn down -> establish (teardown-first)
// socket connected -> grace reset above; the watchdog verifies over the next window.
// socket present-but-disconnected -> socket.io's own reconnection already owns it.
}
function startWatchdog() { stopWatchdog(); watchdogTimer = setInterval(checkLiveness, 10000); }
function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } }
function browserPlatform() {
try {
const m = navigator.userAgent.match(/(Edg|OPR|Chrome|Firefox|Version)\/(\d+)/);
if (m) { const name = { Edg: 'Edge', OPR: 'Opera', Version: 'Safari' }[m[1]] || m[1]; return name + ' ' + m[2]; }
return 'Browser';
} catch (e) { return 'Browser'; }
}
if (typeof window !== 'undefined') { window.__v4ThresholdMs = v4ThresholdMs; window.__v4WatchdogShouldReconnect = watchdogShouldReconnect; }
// ==================== Socket Connection ====================
function connect(serverUrl) {
if (socket) { socket.disconnect(); socket = null; }
@ -752,8 +849,9 @@
socket = io(serverUrl + '/device', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000,
reconnectionDelay: 1000, // v4 canonical: 1s start (was 2s)
reconnectionDelayMax: 30000, // v4 canonical: 30s cap, within the ~30-60s band (was 10s)
randomizationFactor: 0.2, // v4 canonical: ±20% jitter (was the socket.io 0.5 default)
timeout: 20000,
// Prefer WebSocket but allow polling fallback. Socket.IO default is
// polling-first with an upgrade dance that's fragile on TV WebKits
@ -765,6 +863,17 @@
transports: ['websocket', 'polling'],
});
// v4 liveness: a fresh socket is assumed alive; DIS-arm until a heartbeat-ack re-arms; pick a
// fresh jittered window for this connection. markAlive on ANY inbound (app events via onAny +
// the engine ping) refreshes the SILENCE timer only — it does NOT arm.
lastServerMessageAt = Date.now();
livenessConfirmed = false;
livenessWindowMs = v4ThresholdMs(Math.random());
socket.onAny(markAlive);
socket.io.on('ping', markAlive);
// v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack.
socket.on('device:heartbeat-ack', () => { livenessConfirmed = true; });
socket.on('connect', () => {
console.log('Connected');
register();
@ -773,6 +882,7 @@
socket.on('disconnect', () => {
console.log('Disconnected');
stopHeartbeat();
stopWatchdog(); // socket.io owns the reconnect once it KNOWS it's down; watchdog is for half-open only
});
socket.on('connect_error', (err) => {
@ -797,6 +907,7 @@
}
startHeartbeat();
startWatchdog(); // v4: arm-gated half-open watchdog (no-op until a heartbeat-ack arms it)
startPlaylistRefresh();
startVersionCheck();
});
@ -1082,6 +1193,12 @@
screen_width: screen.width,
screen_height: screen.height,
};
// v4 client identity block — additive, canonical snake_case (same shape as APK/.wgt so the
// server consumes one thing). Backward-compatible: an old server ignores unknown fields.
data.client_type = 'player';
data.client_version = PLAYER_VERSION;
data.platform = browserPlatform();
data.contract_version = 'v4';
// Browser fingerprint (survives localStorage clear)
data.fingerprint = generateBrowserFingerprint();
console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`);
@ -2193,7 +2310,18 @@
} catch {}
}
requestWakeLock();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') requestWakeLock(); });
// v4 browser half-open triggers: tab foreground / bfcache resume / network change all drive the
// SAME liveness check + #148 teardown-first reconnect (checkLiveness -> connect() only if the
// socket is armed+connected+silent-past-window). checkLiveness no-ops on a healthy socket, so a
// visibility change does NOT spawn a duplicate socket (the classic browser bug).
document.addEventListener('visibilitychange', () => {
// On becoming visible after a (possibly throttled/frozen) background stint, do the fresh
// liveness check — reset the grace and reconnect ONLY if genuinely dead; never spuriously
// tear down a live socket on the hidden->visible gap.
if (document.visibilityState === 'visible') { requestWakeLock(); verifyLivenessSoon(); }
});
window.addEventListener('pageshow', verifyLivenessSoon); // sleep/resume via bfcache restore
window.addEventListener('online', verifyLivenessSoon); // network switch (wifi<->cellular)
// Register service worker for offline content caching
if ('serviceWorker' in navigator) {

View file

@ -7,6 +7,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa
const { accessContext } = require('../lib/tenancy');
const { stripDeviceSecrets } = require('../lib/device-sanitize');
const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate');
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings preservation
// List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
@ -85,6 +86,14 @@ router.get('/unassigned', (req, res) => {
res.json(devices);
});
// #150: "previously removed devices" — fingerprint-keyed settings snapshots for the caller's
// current workspace, for the operator re-adopt flow (changed-fingerprint case). MUST be
// declared before GET '/:id' or Express matches 'removed' as an :id. Read-scoped to workspace.
router.get('/removed', (req, res) => {
if (!req.workspaceId) return res.json([]);
res.json(deviceSettings.listRemoved(req.workspaceId));
});
// Get single device with telemetry history
router.get('/:id', (req, res) => {
const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id);
@ -202,6 +211,11 @@ router.put('/:id', (req, res) => {
if (!device) return;
const { name, notes, timezone, orientation, default_content_id, layout_id } = req.body;
// #150: validate orientation against the known enum (previously accepted any string, which
// let a bad value reach the player -> unknown rotation falls back to landscape silently).
if (orientation !== undefined && !deviceSettings.ORIENTATIONS.has(orientation)) {
return res.status(400).json({ error: `Invalid orientation. Allowed: ${[...deviceSettings.ORIENTATIONS].join(', ')}` });
}
// Whitelist allowed fields to prevent SQL injection via field names
const ALLOWED_FIELDS = ['name', 'notes', 'timezone', 'orientation', 'default_content_id'];
const updates = [];
@ -253,11 +267,36 @@ router.post('/:id/unblock', (req, res) => {
res.json({ success: true, id: req.params.id, blocked: false });
});
// #150: re-adopt — apply a removed device's saved settings onto device :id. For the case the
// fingerprint did NOT auto-match (factory reset / new hardware), so the automatic re-pair
// restore couldn't fire. Auth: caller can write device :id (checkDeviceOwnership) AND the
// snapshot belongs to the SAME workspace as the device (no cross-tenant apply).
router.post('/:id/re-adopt', (req, res) => {
const device = checkDeviceOwnership(req, res);
if (!device) return;
const { fingerprint } = req.body || {};
if (!fingerprint) return res.status(400).json({ error: 'fingerprint required' });
const snap = deviceSettings.getByFingerprint(fingerprint);
if (!snap) return res.status(404).json({ error: 'No saved settings for that fingerprint' });
if (snap.workspace_id !== device.workspace_id) {
return res.status(403).json({ error: 'Saved settings belong to a different workspace' });
}
deviceSettings.applyToDevice(req.params.id, fingerprint);
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
console.log(`[#150] re-adopted settings (fp ${fingerprint.slice(0, 8)}…) onto device ${req.params.id} by user ${req.user.id}`);
res.json(stripDeviceSecrets(updated));
});
// Delete device
router.delete('/:id', (req, res) => {
const device = checkDeviceOwnership(req, res);
if (!device) return;
// #150: snapshot this device's settings (keyed by its fingerprint) BEFORE the row dies,
// so a re-pair of the SAME physical device restores orientation/name/playlist/etc instead
// of silently resetting to defaults. No-op if the device has no fingerprint link yet.
try { deviceSettings.snapshot(req.params.id); } catch (e) { console.warn(`[#150] settings snapshot failed for ${req.params.id}: ${e.message}`); }
// Clean up related data (playlist is NOT deleted — may be shared with other devices)
db.prepare('DELETE FROM schedules WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM screenshots WHERE device_id = ?').run(req.params.id);

View file

@ -214,6 +214,7 @@ router.get('/:id', requirePlaylistRead, (req, res) => {
ORDER BY pi.sort_order ASC
`).all(req.params.id);
const displayCount = db.prepare('SELECT COUNT(*) as count FROM devices WHERE playlist_id = ?').get(req.params.id).count;
for (const it of items) it.schedules = schedulesForItem(it.id); // #156: editor read-path needs the blocks (mirror :351)
res.json({ ...req.playlist, items, item_count: items.length, display_count: displayCount });
});

View file

@ -212,6 +212,42 @@ router.post('/preview', (req, res) => {
res.send(html);
});
// Preview sessions — ephemeral store so the preview iframe loads via src (not srcdoc)
// and bypasses the dashboard CSP that would block the widget's inline scripts.
const previewStore = new Map();
const PREVIEW_TTL = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [key, entry] of previewStore) {
if (now - entry.created > PREVIEW_TTL) previewStore.delete(key);
}
}, 60 * 1000).unref();
router.post('/preview-session', (req, res) => {
const { widget_type, config } = req.body || {};
if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' });
if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' });
const id = uuidv4();
const html = renderWidgetHtml(widget_type, config || {});
previewStore.set(id, { html, widget_type, created: Date.now() });
res.json({ id, url: `/api/widgets/preview-session/${id}` });
});
router.get('/preview-session/:id', (req, res) => {
const entry = previewStore.get(req.params.id);
if (!entry) return res.status(410).send('Preview expired');
if (Date.now() - entry.created > PREVIEW_TTL) {
previewStore.delete(req.params.id);
return res.status(410).send('Preview expired');
}
let html = entry.html;
if (req.workspaceId) html = inlineUserContent(html, req.workspaceId);
res.removeHeader('X-Frame-Options');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'text/html');
res.send(html);
});
function renderClock(c) {
return `<!DOCTYPE html><html><head><style>
* { margin:0; padding:0; box-sizing:border-box; }

View file

@ -90,13 +90,17 @@ const dashboardCsp = helmet.contentSecurityPolicy({
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
// Cloudflare Web Analytics: the beacon SCRIPT (static.cloudflareinsights.com) must be allowed to
// load, AND the beacon must be allowed to POST its data back (connect-src -> cloudflareinsights.com).
// Both are required — with only the script entry the beacon loads but silently can't report.
scriptSrc: ["'self'", 'https://static.cloudflareinsights.com'],
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
styleSrcAttr: ["'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
mediaSrc: ["'self'", 'blob:', 'https:'],
connectSrc: ["'self'", 'wss:', 'ws:', 'https:'],
// 'wss:'/'ws:' keep the dashboard's socket.io connection working; the CF entry lets the beacon report.
connectSrc: ["'self'", 'wss:', 'ws:', 'https:', 'https://cloudflareinsights.com'],
fontSrc: ["'self'", 'data:'],
frameSrc: ["'self'", 'https://www.youtube.com', 'https://youtube.com'],
objectSrc: ["'none'"],
@ -125,6 +129,7 @@ app.use((req, res, next) => {
if (req.path.startsWith('/player')) return next();
if (req.path === '/docs') return next(); // Redoc API reference needs a relaxed CSP
if (req.path.startsWith('/api/widgets/') && req.path.endsWith('/render')) return next();
if (req.path.startsWith('/api/widgets/preview-session/')) return next();
if (req.path.startsWith('/api/kiosk/') && req.path.endsWith('/render')) return next();
return dashboardCsp(req, res, next);
});
@ -532,7 +537,9 @@ const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS } = require('./config/a
// Public device-render endpoints + the memory-heavy preview limiter must be registered
// BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first.
app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
app.get('/api/widgets/preview-session/:id', (req, res, next) => { req._skipAuth = true; next(); });
app.use('/api/widgets/preview', rateLimit(60000, 30)); // base64 inline = memory-intensive
app.use('/api/widgets/preview-session', rateLimit(60000, 30)); // preview session creation retains rendered HTML in memory for 5min
app.get('/api/kiosk/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
for (const r of PUBLIC_ROUTERS) {
@ -643,6 +650,29 @@ app.get('/api/update/check', (req, res) => {
});
});
// Exit-signal contract v1 — beacon transport (reliable-on-unload). Clients that can't reliably
// socket.emit at death (browser/Tizen pagehide, APK crash where async emit won't flush) POST their
// manner-of-death here via navigator.sendBeacon / blocking HTTP. Token-authed (there's no JWT/socket
// session at unload time); PUBLIC (mounted before requireAuth). Sets offline_reason exactly like the
// device:exit socket handler — the later Offline transition resolves + surfaces it. NEVER triggers
// offline itself (additive only). Always 204 (never error a dying client; never leak an id/token oracle).
app.post('/api/device/exit', (req, res) => {
const { db } = require('./db/database');
const liveness = require('./lib/liveness');
const { device_id, device_token, reason, detail } = req.body || {};
if (!device_id || typeof device_token !== 'string') return res.status(204).end();
const row = db.prepare('SELECT device_token FROM devices WHERE id = ?').get(device_id);
let ok = false;
try {
ok = !!(row && row.device_token && device_token.length === row.device_token.length &&
crypto.timingSafeEqual(Buffer.from(row.device_token), Buffer.from(device_token)));
} catch (_) { ok = false; }
if (!ok) return res.status(204).end();
const e = liveness.sanitizeExitReason(reason, detail); // unknown/invalid -> null -> device falls to 'silent'
if (e) db.prepare("UPDATE devices SET offline_reason = ?, offline_reason_at = strftime('%s','now'), offline_detail = ? WHERE id = ?").run(e.reason, e.detail, device_id);
res.status(204).end();
});
// (Content file endpoint moved above protected routes)
// (Screenshot route moved above protected routes)

View file

@ -4,10 +4,38 @@ const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
const statusLogWriter = require('../lib/status-log-writer');
const { chunkedDelete, currentBand, yieldTick } = require('../lib/chunked-prune'); // #146 non-blocking sweeps
const liveness = require('../lib/liveness'); // v4 core pass: server-derived 3-state liveness
// Track connected device sockets: deviceId -> { socketId, lastHeartbeat }
const deviceConnections = new Map();
// FIX 2: version-agnostic reconnect-frequency signal (every client reconnects the same way). A
// rolling window of recent (re)register timestamps per device -> "degraded-reconnecting" when it churns.
let _io = null; // captured in startHeartbeatChecker so livenessFor() can check namespace presence
const RECONNECT_WINDOW_MS = 60000;
const reconnectTimes = new Map(); // deviceId -> [timestamps within the window]
function recordReconnect(deviceId, now = Date.now()) {
const arr = (reconnectTimes.get(deviceId) || []).filter(t => now - t < RECONNECT_WINDOW_MS);
arr.push(now);
reconnectTimes.set(deviceId, arr);
}
function recentReconnects(deviceId, now = Date.now()) {
const arr = (reconnectTimes.get(deviceId) || []).filter(t => now - t < RECONNECT_WINDOW_MS);
if (arr.length) reconnectTimes.set(deviceId, arr); else reconnectTimes.delete(deviceId);
return arr.length;
}
// Server-derived liveness for a device — from socket presence + heartbeat age + reconnect churn ONLY
// (all version-agnostic). A disconnected device is a clean 'offline' (normal state, not an error).
function livenessFor(deviceId) {
const conn = deviceConnections.get(deviceId);
const deviceNs = _io ? _io.of('/device') : null;
const connected = !!(conn && deviceNs && deviceNs.sockets.has(conn.socketId));
const lastHeartbeatAgeMs = conn ? (Date.now() - conn.lastHeartbeat) : Infinity;
return liveness.deriveLiveness({ connected, lastHeartbeatAgeMs, recentReconnects: recentReconnects(deviceId) });
}
function startHeartbeatChecker(io) {
_io = io; // FIX 2: for livenessFor() namespace-presence checks
// #146: startup sweep is chunked + async + fire-and-forget + NOT band-gated, so a
// bloated device_status_log self-heals on next deploy WITHOUT freezing boot (the old
// whole-table sort froze boot 40-48s -> healthcheck fail -> restart loop). It
@ -56,16 +84,25 @@ function startHeartbeatChecker(io) {
const sock = deviceNs.sockets.get(conn.socketId);
if (sock) { try { sock.disconnect(true); } catch (_) { /* already gone */ } }
}
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
// Exit-signal contract: this timeout path is the classic 'silent' case (froze, no clean
// disconnect, no signal) — COALESCE annotates 'silent' unless a device:exit reason arrived
// this session (e.g. a crash emit that beat the freeze). Pure annotation; detection unchanged.
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now'), offline_reason = COALESCE(offline_reason, 'silent'), offline_reason_at = COALESCE(offline_reason_at, strftime('%s','now')) WHERE id = ?")
.run(device.id);
deviceConnections.delete(device.id);
const _off = db.prepare("SELECT offline_reason, offline_detail, client_type FROM devices WHERE id = ?").get(device.id) || {};
// Notify dashboard (workspace-scoped via the device's room).
emitToWorkspace(dashboardNs, deviceRoom(device.id), 'dashboard:device-status', {
device_id: device.id,
status: 'offline',
liveness: 'offline', // FIX 2: derived — no live socket => offline (a normal state, not an error)
offline_reason: _off.offline_reason || 'silent', // exit-signal contract: manner-of-death
offline_detail: _off.offline_detail || null,
client_type: _off.client_type || null,
telemetry: null
});
reconnectTimes.delete(device.id); // clear churn history on a clean offline
console.log(`Device ${device.id} marked offline (heartbeat timeout)`);
// #146: batch through the coalescing writer (was an immediate INSERT here).
@ -202,6 +239,9 @@ module.exports = {
getConnection,
getAllConnections,
getConnectedCount,
recordReconnect, // FIX 2
recentReconnects, // FIX 2
livenessFor, // FIX 2
pruneProvisioningDevices,
accrueUsage,
pruneUsageDaily,

View file

@ -0,0 +1,192 @@
'use strict';
// #156 — playlist item schedule saved but not shown in editor.
//
// GET /playlists/:id (the editor's load path) built its `items` array but never
// attached schedules, so the Web UI rendered "always plays" for items that DO
// have a live schedule. Worse: the editor's unchanged-save then re-PUTs whatever
// it loaded, and PUT /schedules is a wholesale DELETE+INSERT — so loading an
// item as "no schedule" and saving silently WIPED the real schedule.
//
// The fix mirrors GET /:id/items (playlists.js:351): attach schedulesForItem()
// to each item in GET /:id. These three tests exercise the read path, the write
// round-trip, and the wipe-trap regression via the REAL editor load->save flow.
//
// Harness matches api.test.js: boot the real server.js against an isolated DB and
// drive it over HTTP with a JWT. Node built-ins + better-sqlite3 (dep) only.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const PORT = 3900 + (crypto.randomBytes(1)[0] % 90); // avoid clashes with sibling subprocess suites
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-156-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-156-test-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc;
const S = {}; // shared fixtures
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null;
try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const auth = () => ({ headers: { Authorization: 'Bearer ' + S.jwt, 'Content-Type': 'application/json' } });
const post = (obj) => ({ method: 'POST', ...auth(), body: JSON.stringify(obj) });
const put = (obj) => ({ method: 'PUT', ...auth(), body: JSON.stringify(obj) });
// --- fixture helpers ---------------------------------------------------------
async function addItem() {
const r = await jfetch(`/api/playlists/${S.playlistId}/items`, post({ widget_id: S.widgetId }));
assert.equal(r.status, 201, 'add item should 201: ' + JSON.stringify(r.body));
return r.body.id;
}
async function putSchedules(itemId, blocks) {
return jfetch(`/api/playlists/${S.playlistId}/items/${itemId}/schedules`, put({ blocks }));
}
async function loadItem(itemId) {
// The editor's read path: GET /:id then read the item out of .items.
const r = await jfetch(`/api/playlists/${S.playlistId}`, auth());
assert.equal(r.status, 200, 'GET /:id should 200');
const it = r.body.items.find(i => i.id === itemId);
assert.ok(it, 'item should be present in GET /:id payload');
return it;
}
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
// first user -> platform_admin + a workspace; its JWT authorizes the editor routes.
const reg = await jfetch('/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'u156@test.local', password: 'test12345', name: 'U156' }),
});
S.jwt = reg.body.token;
S.widgetId = (await jfetch('/api/widgets', post({ name: 'W156', widget_type: 'clock', config: {} }))).body.id;
S.playlistId = (await jfetch('/api/playlists', post({ name: 'PL156' }))).body.id;
S.dbPath = path.join(DATA_DIR, 'db', 'remote_display.db');
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
// A schedule block as emitted by schedulesForItem() (playlists.js:121) and consumed
// by the editor (frontend/js/views/playlists.js:749) — the shape both sides agree on.
function assertBlockShape(b) {
assert.ok(b && typeof b === 'object', 'block is an object');
assert.ok(Array.isArray(b.days), 'block.days is an array');
assert.equal(typeof b.start, 'string', 'block.start is a string');
assert.equal(typeof b.end, 'string', 'block.end is a string');
assert.ok('start_date' in b, 'block has start_date');
assert.ok('end_date' in b, 'block has end_date');
}
// 1. RENDER — a schedule written directly to the table must surface on GET /:id
// (the read-path wiring), not come back undefined/[] as "always plays".
test('GET /:id attaches schedules to each item (render path)', async () => {
const itemId = await addItem();
// Write a schedule straight to the table (a second WAL connection; the server sees it),
// proving the READ path independent of the write route.
const sdb = new (require('better-sqlite3'))(S.dbPath, { timeout: 5000 });
sdb.prepare(
'INSERT INTO playlist_item_schedules (id, playlist_item_id, active_days, start_time, end_time, start_date, end_date, sort_order) VALUES (?,?,?,?,?,?,?,?)'
).run(crypto.randomUUID(), itemId, '1,2,3', '09:00', '17:00', null, null, 0);
sdb.close();
const it = await loadItem(itemId);
assert.ok(Array.isArray(it.schedules), 'item.schedules present (not undefined)');
assert.equal(it.schedules.length, 1, 'item.schedules non-empty');
assertBlockShape(it.schedules[0]);
assert.deepEqual(it.schedules[0].days, [1, 2, 3], 'days decoded from active_days');
assert.equal(it.schedules[0].start, '09:00');
assert.equal(it.schedules[0].end, '17:00');
assert.equal(it.schedules[0].start_date, null);
assert.equal(it.schedules[0].end_date, null);
});
// 2. ROUND-TRIP — the editor's write path (PUT .../schedules) must reflect create,
// edit, and delete when read back through GET /:id.
test('schedule create/edit/delete round-trips through the editor read path', async () => {
const itemId = await addItem();
// create
let r = await putSchedules(itemId, [{ days: [1, 2, 3, 4, 5], start: '08:00', end: '18:00', start_date: null, end_date: null }]);
assert.equal(r.status, 200, 'create PUT 200: ' + JSON.stringify(r.body));
let it = await loadItem(itemId);
assert.equal(it.schedules.length, 1);
assert.deepEqual(it.schedules[0].days, [1, 2, 3, 4, 5]);
assert.equal(it.schedules[0].start, '08:00');
assert.equal(it.schedules[0].end, '18:00');
// edit
r = await putSchedules(itemId, [{ days: [6], start: '10:00', end: '12:00', start_date: '2026-01-01', end_date: '2026-12-31' }]);
assert.equal(r.status, 200, 'edit PUT 200');
it = await loadItem(itemId);
assert.equal(it.schedules.length, 1);
assert.deepEqual(it.schedules[0].days, [6]);
assert.equal(it.schedules[0].start, '10:00');
assert.equal(it.schedules[0].end, '12:00');
assert.equal(it.schedules[0].start_date, '2026-01-01');
assert.equal(it.schedules[0].end_date, '2026-12-31');
// delete ([] = no schedule = always plays)
r = await putSchedules(itemId, []);
assert.equal(r.status, 200, 'delete PUT 200');
it = await loadItem(itemId);
assert.deepEqual(it.schedules, [], 'schedules cleared -> empty array');
});
// 3. WIPE-TRAP GUARD (the one that matters) — simulate the editor's load -> unchanged
// save. The editor seeds its blocks from item.schedules (frontend:749) and doSave()
// re-PUTs exactly those (frontend:828-831). If the load returns no schedules (the
// bug), the unchanged save PUTs [] and the DELETE+INSERT wipes the live schedule.
// With the fix, the load returns the blocks, the re-PUT re-inserts them, survives.
test('unchanged editor save does NOT wipe an existing schedule', async () => {
const itemId = await addItem();
// item starts WITH a schedule
await putSchedules(itemId, [{ days: [0, 1, 2, 3, 4, 5, 6], start: '00:00', end: '24:00', start_date: null, end_date: null }]);
// --- editor LOAD (frontend/js/views/playlists.js:749) ---
const it = await loadItem(itemId);
const seeded = (it.schedules || []).map(b => ({
days: Array.isArray(b.days) ? [...b.days] : [],
start: b.start || '00:00',
end: b.end || '24:00',
start_date: b.start_date || '',
end_date: b.end_date || '',
}));
// --- editor SAVE with no changes (doSave, frontend:828-831) ---
const payload = seeded.map(b => ({
days: b.days, start: b.start, end: b.end,
start_date: b.start_date || null, end_date: b.end_date || null,
}));
const r = await putSchedules(itemId, payload);
assert.equal(r.status, 200, 'unchanged save PUT 200: ' + JSON.stringify(r.body));
// schedule must STILL exist (pre-fix: seeded === [] -> payload [] -> wiped -> fails here)
const after = await loadItem(itemId);
assert.equal(after.schedules.length, 1, 'schedule survived the unchanged save (no silent wipe)');
assert.deepEqual(after.schedules[0].days, [0, 1, 2, 3, 4, 5, 6]);
assert.equal(after.schedules[0].start, '00:00');
assert.equal(after.schedules[0].end, '24:00');
});

View file

@ -0,0 +1,58 @@
// OTA breaker — the -patchN transition. The legacy '-patchN' release scheme (e.g. 1.9.2-patch3) parses
// as a semver prerelease, which made the superseded-prerelease guard STRAND the old fleet: with clean
// 1.9.3 as latest, a 1.9.2-patchN device was refused the update. isReleased() now treats -patchN as a
// shipped release so it's offered, while GENUINE prereleases (-beta/-rc/-alpha) keep prerelease semantics.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const ota = require('../lib/ota-breaker');
const LATEST = '1.9.3'; // the clean-semver release
const T = 1_000_000;
test('-patchN fleet is OFFERED the newer stable (the transition fix)', () => {
ota.reset();
for (const v of ['1.9.2-patch3', '1.9.2-patch4', '1.9.1-patch2', '1.9.2-PATCH3' /* case-insensitive */]) {
const d = ota.decide(v, LATEST, null, T);
assert.equal(d.update_available, true, `${v} -> 1.9.3 should be offered (was 'superseded-prerelease')`);
assert.equal(d.reason, 'offer');
}
});
test('GENUINE older-core prereleases are STILL superseded (no offer) — unchanged', () => {
ota.reset();
for (const v of ['1.9.1-beta4', '1.9.2-beta6', '1.9.0-rc1', '1.8.5-alpha2']) {
const d = ota.decide(v, LATEST, null, T);
assert.equal(d.update_available, false, `${v} is a genuine prerelease of an older core -> no offer`);
assert.equal(d.reason, 'superseded-prerelease');
}
});
test('clean older releases still offered; equal is up-to-date; newer never downgraded', () => {
ota.reset();
assert.equal(ota.decide('1.9.2', LATEST, null, T).reason, 'offer', 'clean 1.9.2 -> offered');
assert.equal(ota.decide('1.9.1', LATEST, null, T).reason, 'offer', 'clean 1.9.1 -> offered');
assert.equal(ota.decide('1.7.12', LATEST, null, T).reason, 'offer', 'old clean release -> offered');
assert.equal(ota.decide('1.9.3', LATEST, null, T).reason, 'up-to-date');
assert.equal(ota.decide('1.9.4', LATEST, null, T).reason, 'client-newer', 'never downgrade a newer core');
});
test('a prerelease of a HIGHER core is client-newer, not offered (e.g. a 1.9.4-beta1 tester)', () => {
ota.reset();
assert.equal(ota.decide('1.9.4-beta1', LATEST, null, T).reason, 'client-newer');
// and a -patchN of a higher core is likewise newer (never a downgrade)
assert.equal(ota.decide('1.9.4-patch1', LATEST, null, T).reason, 'client-newer');
});
test('regression: unrecognized/garbage still refused; the fix did not loosen the phantom guard', () => {
ota.reset();
assert.equal(ota.decide('banana', LATEST, null, T).reason, 'unrecognized-version');
assert.equal(ota.decide('', LATEST, null, T).reason, 'no-version');
});
test('regression: with a PRERELEASE server (beta4 latest), superseded-prerelease still fires for older betas', () => {
ota.reset();
// mirrors the existing ota-breaker.test.js scenario — a -patchN client change must not disturb it
const d = ota.decide('1.9.1-beta4', '1.9.2-beta4', null, T);
assert.equal(d.reason, 'superseded-prerelease');
// a same-core older beta against a beta server is offerable (rate path), unchanged
assert.equal(ota.decide('1.9.2-beta3', '1.9.2-beta4', null, T).reason, 'offer');
});

View file

@ -55,8 +55,11 @@ test('non-blocking: 300k-row single-device backlog trims in many batches, loop s
assert.equal(count('flapper'), 300000, 'seeded 300k');
// Event-loop responsiveness probe: a 10ms ticker; the max gap between ticks is the
// worst synchronous block during the prune. A single unbatched DELETE would freeze
// it for seconds; chunked+yield keeps every gap small.
// worst synchronous block during the prune. A single unbatched DELETE of 300k rows would
// freeze it for SECONDS; chunked+yield keeps every gap well under a second. The bar is set to
// catch that multi-second freeze while tolerating shared-CI-runner noise (a strict sub-300ms
// bar flakes under runner contention/GC — the intent is "not a seconds-long freeze", not a
// fixed-latency SLA).
let maxGap = 0, last = Date.now();
const ticker = setInterval(() => { const n = Date.now(); maxGap = Math.max(maxGap, n - last); last = n; }, 10);
@ -65,7 +68,7 @@ test('non-blocking: 300k-row single-device backlog trims in many batches, loop s
assert.equal(count('flapper'), 500, 'trimmed to the cap');
assert.ok(deleted >= 299000, `deleted the backlog (${deleted})`);
assert.ok(maxGap < 250, `no long freeze — max event-loop gap ${maxGap}ms (would be seconds if unbatched)`);
assert.ok(maxGap < 1500, `no seconds-long freeze — max event-loop gap ${maxGap}ms (an unbatched 300k DELETE would be multiple seconds)`);
});
test('band-gate: interval run is a no-op when loaded; startup/normal runs', async () => {

View file

@ -64,8 +64,10 @@ test('storm: bloated-table sweep + flapper + OTA flood — loop stays responsive
clearInterval(ticker);
// 1) THE REAL INVARIANT — NO multi-second freeze. The old whole-table sort would
// freeze the ticker for tens of seconds here.
assert.ok(maxGap < 300, `loop never froze — max event-loop gap ${maxGap}ms (was 40-48s pre-fix)`);
// freeze the ticker for tens of seconds here (40-48s). The bar catches that while
// tolerating shared-CI-runner noise — the intent is "not seconds", not a sub-300ms SLA
// (a strict bar flakes under runner contention/GC, e.g. an observed 417ms on a healthy prune).
assert.ok(maxGap < 1500, `loop never froze — max event-loop gap ${maxGap}ms (was 40-48s pre-fix)`);
// #146 P3.9: the exact tick count is environment-timing-sensitive; assert only that
// the ticker sampled enough to have MEASURED a real gap (>=2), not a brittle count.
assert.ok(ticks >= 2, `ticker sampled the run (${ticks} ticks) — max-gap measurement is meaningful`);

View file

@ -0,0 +1,190 @@
// v4 CORE PASS — server honors the liveness contract uniformly across the MIXED fleet (v4 + old
// pre-v4 + disconnected). Validates: uniform device:heartbeat-ack, the reconnect-window ack-gap fix,
// server-derived liveness, identity capture (degrades on missing), cross-client conformance.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const ioClient = require('../node_modules/socket.io-client');
const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto');
const liveness = require('../lib/liveness');
// ============================ PURE UNIT TESTS (no server) ============================
test('ackableHeartbeat: authed socket is acked', () => {
assert.equal(liveness.ackableHeartbeat('dev1', 'dev1', () => false), true); // authed -> known regardless
});
test('ackableHeartbeat: KNOWN device mid-reconnect (not-yet-authed socket, resolvable id) is acked — the ack-gap fix', () => {
assert.equal(liveness.ackableHeartbeat(null, 'devKnown', (id) => id === 'devKnown'), true);
});
test('ackableHeartbeat: anonymous (no device_id) NOT acked — degrade-safe', () => {
assert.equal(liveness.ackableHeartbeat(null, undefined, () => true), false);
});
test('ackableHeartbeat: unknown device_id NOT acked', () => {
assert.equal(liveness.ackableHeartbeat(null, 'ghost', () => false), false);
});
test('ackableHeartbeat: BOTH identity paths acked identically (id-agnostic — device_id resolves)', () => {
const exists = (id) => id === 'viaToken' || id === 'viaFingerprint';
assert.equal(liveness.ackableHeartbeat(null, 'viaToken', exists), true);
assert.equal(liveness.ackableHeartbeat(null, 'viaFingerprint', exists), true);
});
test('deriveLiveness: disconnected device -> offline (normal state, not an error)', () => {
assert.equal(liveness.deriveLiveness({ connected: false, lastHeartbeatAgeMs: 999999, recentReconnects: 9 }), 'offline');
});
test('deriveLiveness: OLD client (connected + heartbeating, NO v4 signals) -> healthy (version-agnostic)', () => {
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 5000, recentReconnects: 0 }), 'healthy');
});
test('deriveLiveness: connected but reconnect-churn -> degraded', () => {
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 5000, recentReconnects: 3 }), 'degraded');
});
test('deriveLiveness: connected but silent past window -> degraded', () => {
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 40000, recentReconnects: 0 }), 'degraded');
});
test('captureIdentity: full v4 block captured verbatim', () => {
assert.deepEqual(liveness.captureIdentity({ client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' }),
{ client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' });
});
test('captureIdentity: OLD client (no block) -> legacy/unknown defaults, NEVER fails', () => {
assert.deepEqual(liveness.captureIdentity({}), { client_type: 'legacy', client_version: 'unknown', platform: 'unknown', contract_version: 'legacy' });
assert.deepEqual(liveness.captureIdentity(undefined), { client_type: 'legacy', client_version: 'unknown', platform: 'unknown', contract_version: 'legacy' });
});
test('captureIdentity: PARTIAL block degrades per-field', () => {
assert.deepEqual(liveness.captureIdentity({ client_type: 'apk' }), { client_type: 'apk', client_version: 'unknown', platform: 'unknown', contract_version: 'legacy' });
});
// ============================ CROSS-CLIENT CONFORMANCE (source diff) ============================
test('cross-client conformance: threshold + arm + identity IDENTICAL across APK/.wgt/player', () => {
const root = path.join(__dirname, '..', '..');
const wgt = fs.readFileSync(path.join(root, 'tizen/js/app.js'), 'utf8');
const player = fs.readFileSync(path.join(root, 'server/player/index.html'), 'utf8');
const apk = fs.readFileSync(path.join(root, 'android/app/src/main/java/com/remotedisplay/player/service/LivenessWatchdog.kt'), 'utf8');
// threshold 45000 ± 10000 — identical formula constants in all three
assert.match(wgt, /THRESHOLD_BASE_MS = 45000, THRESHOLD_JITTER_MS = 10000/);
assert.match(player, /V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000/);
assert.match(apk, /THRESHOLD_BASE_MS = 45_000L/); assert.match(apk, /THRESHOLD_JITTER_MS = 10_000L/);
// arm event name — identical
for (const s of [wgt, player, apk]) assert.match(s, /device:heartbeat-ack/);
// watchdog backoff params — .wgt/player io opts AND APK LivenessWatchdog all 1000/30000/0.2
assert.match(wgt, /reconnectionDelay: 1000/); assert.match(wgt, /reconnectionDelayMax: 30000/); assert.match(wgt, /randomizationFactor: 0.2/);
assert.match(player, /reconnectionDelay: 1000/); assert.match(player, /reconnectionDelayMax: 30000/); assert.match(player, /randomizationFactor: 0.2/);
assert.match(apk, /BACKOFF_BASE_MS = 1_000L/); assert.match(apk, /BACKOFF_CAP_MS = 30_000L/);
});
test('cross-client conformance FINDING: APK socket.io TRANSPORT backoff diverges (60s/0.5 vs 30s/0.2)', () => {
const apkWs = fs.readFileSync(path.join(__dirname, '..', '..', 'android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt'), 'utf8');
// Documented divergence (from the /player QA pass): the APK's IO.Options transport backoff is
// 60000/0.5, not the canonical 30000/0.2 the .wgt/player use. Assert it so the finding is tracked.
assert.match(apkWs, /reconnectionDelayMax = 60_000/);
assert.match(apkWs, /randomizationFactor = 0.5/);
});
// ============================ E2E: MIXED FLEET against the real server ============================
const PORT = 3968;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-v4core-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-v4core.log');
let proc, JWT;
const sleep = ms => new Promise(r => setTimeout(r, ms));
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false;
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@test.local', password: 'test12345', name: 'Op' }) })).json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
// open a socket, register with regMsg, resolve {sock, data} on device:registered (socket stays OPEN)
function openAndRegister(regMsg) {
return new Promise((resolve, reject) => {
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
sock.on('connect', () => sock.emit('device:register', regMsg));
sock.on('device:registered', (d) => resolve({ sock, data: d }));
setTimeout(() => reject(new Error('register timeout')), 4000);
});
}
// emit a heartbeat, resolve true if device:heartbeat-ack arrives within `ms`, else false
function ackWithin(sock, hbMsg, ms = 1200) {
return new Promise((resolve) => {
let done = false; const fin = v => { if (!done) { done = true; resolve(v); } };
sock.once('device:heartbeat-ack', () => fin(true));
sock.emit('device:heartbeat', hbMsg);
setTimeout(() => fin(false), ms);
});
}
test('PRIMARY: uniform ack — a v4 device (pairing path) is acked from the shared handler', async () => {
const { sock, data } = await openAndRegister({ pairing_code: '111111', fingerprint: 'fp-v4a', device_info: {}, client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' });
assert.ok(await ackWithin(sock, { device_id: data.device_id, telemetry: {} }), 'v4 device heartbeat should be acked');
sock.close();
});
test('PRIMARY: uniform ack — the reconnect path (device_id+token) is acked identically', async () => {
const first = await openAndRegister({ pairing_code: '222222', fingerprint: 'fp-v4b', device_info: {} });
const creds = { id: first.data.device_id, token: first.data.device_token }; first.sock.close(); await sleep(300);
const { sock } = await openAndRegister({ device_id: creds.id, device_token: creds.token, fingerprint: 'fp-v4b', device_info: {}, client_type: 'apk', contract_version: 'v4' });
assert.ok(await ackWithin(sock, { device_id: creds.id, telemetry: {} }), 'reconnected device heartbeat should be acked');
sock.close();
});
test('FIX 1 ack-gap: a KNOWN device mid-reconnect (heartbeat BEFORE re-register) is acked', async () => {
const first = await openAndRegister({ pairing_code: '333333', fingerprint: 'fp-gap', device_info: {} });
const knownId = first.data.device_id; first.sock.close(); await sleep(300);
// fresh socket, NOT registered — send a heartbeat carrying the KNOWN device_id
const raw = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
await new Promise(r => raw.on('connect', r));
assert.ok(await ackWithin(raw, { device_id: knownId, telemetry: {} }), 'known device mid-reconnect must be acked so its watchdog stays armed');
raw.close();
});
test('FIX 1 ack-gap: anonymous / unknown socket is NOT acked (degrade-safe)', async () => {
const raw = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
await new Promise(r => raw.on('connect', r));
assert.equal(await ackWithin(raw, { telemetry: {} }, 900), false, 'no device_id -> not acked');
assert.equal(await ackWithin(raw, { device_id: 'ghost-' + crypto.randomBytes(4).toString('hex'), telemetry: {} }, 900), false, 'unknown device_id -> not acked');
raw.close();
});
test('MIXED FLEET: v4 + OLD (no identity block) + anonymous simultaneously — nothing errors, acks correct', async () => {
// v4 client (identity block) and OLD client (NO identity block, no ack consumption) both register+ack.
const v4 = await openAndRegister({ pairing_code: '444444', fingerprint: 'fp-mixv4', device_info: {}, client_type: 'player', client_version: '1.1.0-web', platform: 'Chrome 120', contract_version: 'v4' });
const old = await openAndRegister({ pairing_code: '555555', fingerprint: 'fp-mixold', device_info: { app_version: 'legacy-apk-1.0' } }); // NO identity block
assert.ok(v4.data.device_id && old.data.device_id, 'both v4 and OLD clients registered WITHOUT error on missing identity');
assert.ok(await ackWithin(v4.sock, { device_id: v4.data.device_id, telemetry: {} }), 'v4 acked');
assert.ok(await ackWithin(old.sock, { device_id: old.data.device_id, telemetry: {} }), 'OLD client acked too (harmless — it ignores the ack)');
// anonymous present at the same time -> not acked, server unbothered
const anon = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
await new Promise(r => anon.on('connect', r));
assert.equal(await ackWithin(anon, { telemetry: {} }, 900), false, 'anonymous not acked');
v4.sock.close(); old.sock.close(); anon.close();
// server still healthy after the mixed load
assert.equal((await fetch(BASE + '/api/status')).ok, true, 'server unbroken by the mixed fleet');
});
test('FIX 3 identity capture: v4 -> stored verbatim; OLD -> legacy/unknown (verified via device API)', async () => {
// v4 device, paired, then read back
const v4 = await openAndRegister({ pairing_code: '666666', fingerprint: 'fp-idv4', device_info: {}, client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' });
v4.sock.close();
await fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: '666666', name: 'v4dev' }) });
const v4row = await (await fetch(BASE + '/api/devices/' + v4.data.device_id, { headers: { Authorization: 'Bearer ' + JWT } })).json();
assert.equal(v4row.client_type, 'wgt'); assert.equal(v4row.contract_version, 'v4'); assert.equal(v4row.platform, 'Tizen 6.5');
// OLD device (no identity block), paired, read back -> legacy/unknown
const old = await openAndRegister({ pairing_code: '777777', fingerprint: 'fp-idold', device_info: {} });
old.sock.close();
await fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: '777777', name: 'olddev' }) });
const oldrow = await (await fetch(BASE + '/api/devices/' + old.data.device_id, { headers: { Authorization: 'Bearer ' + JWT } })).json();
assert.equal(oldrow.client_type, 'legacy'); assert.equal(oldrow.contract_version, 'legacy'); assert.equal(oldrow.client_version, 'unknown');
});
test('#148 + degrade-safe hold: a device reconnect yields ONE connection, ack still works', async () => {
const first = await openAndRegister({ pairing_code: '888888', fingerprint: 'fp-148', device_info: {} });
const creds = { id: first.data.device_id, token: first.data.device_token };
// reconnect on a NEW socket (old still open) -> server evicts the old, one connection remains
const second = await openAndRegister({ device_id: creds.id, device_token: creds.token, fingerprint: 'fp-148', device_info: {} });
await sleep(400);
assert.ok(await ackWithin(second.sock, { device_id: creds.id, telemetry: {} }), 'the surviving socket is acked');
const connected = (await (await fetch(BASE + '/api/status')).json()).devices_connected;
assert.ok(connected >= 1, 'device present; #148 single-socket not broken by the ack');
try { first.sock.close(); } catch {} second.sock.close();
});

View file

@ -0,0 +1,99 @@
// CORE targeted fix — the isPlaylistRefresh gate + identity change-detection close the A-bucket:
// A1 (WAL write amplification from a sync identity UPDATE on every ~45-60s refresh) and
// A2 (benign refreshes inflating recentReconnects -> healthy devices shown "Degraded").
const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto');
// in-process DB dir for the heartbeat-service unit tests (isolated from the spawned e2e server)
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-rg-unit-' + crypto.randomBytes(4).toString('hex'));
process.env.SELF_HOSTED = 'true';
fs.mkdirSync(process.env.DATA_DIR, { recursive: true });
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const ioClient = require('../node_modules/socket.io-client');
const liveness = require('../lib/liveness');
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ================= UNIT: change-detection (A1) =================
test('identityChanged: never-stored (null) -> write', () => {
assert.equal(liveness.identityChanged(null, { client_type: 'wgt' }), true);
});
test('identityChanged: identical -> NO write (steady-state reconnect)', () => {
const i = { client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' };
assert.equal(liveness.identityChanged({ ...i }, i), false);
});
test('identityChanged: a real change (new client_version after OTA) -> write', () => {
const cur = { client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' };
assert.equal(liveness.identityChanged(cur, { ...cur, client_version: '1.9.3' }), true);
});
// ================= UNIT: churn logic (A2) via the real heartbeat service =================
const heartbeat = require('../services/heartbeat');
test('A2 flapping: a genuinely-flapping device (3 reconnects in window) -> degraded (not over-corrected)', () => {
const id = 'flap-' + crypto.randomBytes(3).toString('hex');
heartbeat.recordReconnect(id, 1000); heartbeat.recordReconnect(id, 2000); heartbeat.recordReconnect(id, 3000);
assert.equal(heartbeat.recentReconnects(id, 3000), 3);
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 5000, recentReconnects: 3 }), 'degraded');
});
test('A2 healthy: a device with NO recorded reconnects (refreshes gated) -> 0 -> healthy', () => {
const id = 'ok-' + crypto.randomBytes(3).toString('hex');
assert.equal(heartbeat.recentReconnects(id, 1000), 0);
assert.equal(liveness.deriveLiveness({ connected: true, lastHeartbeatAgeMs: 5000, recentReconnects: 0 }), 'healthy');
});
test('A2 window: reconnects older than 60s drop out (a past flap does not stay Degraded forever)', () => {
const id = 'win-' + crypto.randomBytes(3).toString('hex');
heartbeat.recordReconnect(id, 1000); heartbeat.recordReconnect(id, 2000); heartbeat.recordReconnect(id, 3000);
assert.equal(heartbeat.recentReconnects(id, 3000), 3); // in-window -> degraded
assert.equal(heartbeat.recentReconnects(id, 70000), 0); // 67s later -> expired -> healthy again
});
// ================= E2E: the shared !isPlaylistRefresh gate + change-detection =================
const PORT = 3972; const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-rg-e2e-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-rg-e2e.log');
let proc, JWT;
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false; for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/api/status')).ok) { up = true; break; } } catch { /* */ } await sleep(250); }
if (!up) throw new Error('boot fail:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@t.local', password: 'test12345', name: 'Op' }) })).json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
const connect = () => ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
const registerOn = (sock, msg) => new Promise((res, rej) => { sock.once('device:registered', d => res(d)); sock.emit('device:register', msg); setTimeout(() => rej(new Error('reg timeout')), 4000); });
const deviceRow = async (id) => (await (await fetch(`${BASE}/api/devices/${id}`, { headers: { Authorization: 'Bearer ' + JWT } })).json());
const pair = (code, name) => fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: code, name }) });
test('A1 GATE: a same-socket REFRESH does NOT rewrite identity (nor count churn — shared gate)', async () => {
const sock = connect(); await new Promise(r => sock.on('connect', r));
const reg = await registerOn(sock, { pairing_code: '910910', fingerprint: 'fp-rg', device_info: {}, client_type: 'wgt', client_version: '1.9.2', platform: 'Tizen 6.5', contract_version: 'v4' });
await pair('910910', 'rg');
assert.equal((await deviceRow(reg.device_id)).client_type, 'wgt');
// SAME socket re-register (a playlist refresh) carrying a DIFFERENT identity -> MUST be ignored (gated).
await registerOn(sock, { device_id: reg.device_id, device_token: reg.device_token, device_info: {}, client_type: 'CHANGED-ON-REFRESH', client_version: '9.9.9', platform: 'x', contract_version: 'v9' });
await sleep(200);
assert.equal((await deviceRow(reg.device_id)).client_type, 'wgt', 'a refresh must NOT rewrite identity — the !isPlaylistRefresh gate skips recordReconnect + persistIdentity together');
sock.close();
});
test('A1 change-detect: a GENUINE reconnect writes identity when it CHANGED (e.g. an OTA bump)', async () => {
const s1 = connect(); await new Promise(r => s1.on('connect', r));
const reg = await registerOn(s1, { pairing_code: '920920', fingerprint: 'fp-cd', device_info: {}, client_type: 'apk', client_version: '2.0.0', platform: 'Android 11', contract_version: 'v4' });
await pair('920920', 'cd'); s1.close(); await sleep(300);
const s2 = connect(); await new Promise(r => s2.on('connect', r)); // NEW socket -> genuine reconnect
await registerOn(s2, { device_id: reg.device_id, device_token: reg.device_token, device_info: {}, client_type: 'apk', client_version: '2.1.0', platform: 'Android 11', contract_version: 'v4' });
await sleep(200);
assert.equal((await deviceRow(reg.device_id)).client_version, '2.1.0', 'a genuine reconnect with a CHANGED identity writes it');
s2.close();
});
test('KEYSTONES unaffected: the shared uniform ack still fires (L3) and pre-auth grants nothing (L4)', async () => {
const s = connect(); await new Promise(r => s.on('connect', r));
const reg = await registerOn(s, { pairing_code: '930930', fingerprint: 'fp-ka', device_info: {}, client_type: 'wgt', contract_version: 'v4' });
const acked = await new Promise((res) => { let d = false; s.once('device:heartbeat-ack', () => { if (!d) { d = true; res(true); } }); s.emit('device:heartbeat', { device_id: reg.device_id, telemetry: {} }); setTimeout(() => { if (!d) res(false); }, 1000); });
assert.equal(acked, true, 'uniform ack path untouched by the gate fix');
s.close();
});

View file

@ -0,0 +1,129 @@
// Exit-signal PHASE 3 — proof. (A) socket/#148/liveness safety; (B) per-category classification with
// NOTHING misclassified. The JS-client handlers are proven by EXECUTING THE REAL SOURCE blocks (sliced
// out of index.html / app.js) against shimmed window/navigator/socket and firing synthetic death events.
const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto');
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const ioClient = require('../node_modules/socket.io-client');
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ---- harness: run a real client exit-block against shims, capturing what it sends ----
function harness(file, startLine, endLine) {
const src = fs.readFileSync(file, 'utf8').split('\n').slice(startLine - 1, endLine).join('\n');
const beacons = [], socketSends = [], handlers = {};
const windowShim = { addEventListener: (ev, fn) => { (handlers[ev] = handlers[ev] || []).push(fn); }, location: { origin: 'http://srv' } };
const navShim = { sendBeacon: (url, blob) => { beacons.push({ url, ...JSON.parse(blob.__body) }); return true; } };
class BlobShim { constructor(parts, o) { this.__body = parts[0]; this.type = o && o.type; } }
const socketShim = { connected: true, emit: (ev, payload) => { socketSends.push({ ev, ...payload }); } };
const config = { deviceId: 'D1', deviceToken: 'T1', serverUrl: 'http://srv' }; // /player reads config.*
const fn = new Function('window', 'navigator', 'Blob', 'fetch', 'config', 'deviceId', 'deviceToken', 'serverUrl', 'socket', 'JSON', src);
fn(windowShim, navShim, BlobShim, () => Promise.resolve(), config, 'D1', 'T1', 'http://srv', socketShim, JSON);
return { beacons, socketSends, fire: (ev, e) => (handlers[ev] || []).forEach(f => f(e)), handlers };
}
const WINDOW = 'window-target'; // sentinel for ev.target === window (real error event on window)
// ============ PART B — /player classification (real source, lines 349-385) ============
const PLAYER = path.join(__dirname, '../player/index.html');
test('B/player CRASH: uncaught error + unhandledrejection -> crashed (via sendBeacon, NOT the socket)', () => {
let h = harness(PLAYER, 349, 385); h.fire('error', { error: { message: 'boom' }, target: undefined });
assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed');
assert.equal(h.socketSends.length, 0, '/player uses the beacon channel only — no send over the dying socket');
h = harness(PLAYER, 349, 385); h.fire('unhandledrejection', { reason: { message: 'rej' } });
assert.equal(h.beacons[0].reason, 'crashed');
});
test('B/player NO-MISCLASSIFY: a RESOURCE load error (img/script) is NOT a crash', () => {
const h = harness(PLAYER, 349, 385); h.fire('error', { target: { src: 'https://x/img.png' } });
assert.equal(h.beacons.length, 0, 'resource error must not emit crashed');
});
test('B/player CLEAN-CLOSE: pagehide(persisted=false) -> clean_exit', () => {
const h = harness(PLAYER, 349, 385); h.fire('pagehide', { persisted: false });
assert.equal(h.beacons[0].reason, 'clean_exit');
});
test('B/player BACKGROUNDING: pagehide(persisted=true) bfcache suspend -> NO exit (not a death)', () => {
const h = harness(PLAYER, 349, 385); h.fire('pagehide', { persisted: true });
assert.equal(h.beacons.length, 0, 'a suspend must NOT emit clean_exit');
assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'exit block wires NO visibilitychange -> hidden never emits exit');
});
test('B/player IDEMPOTENT: crash then pagehide -> only crashed (crash not relabelled clean_exit)', () => {
const h = harness(PLAYER, 349, 385); h.fire('error', { error: { message: 'boom' } }); h.fire('pagehide', { persisted: false });
assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed');
});
// ============ PART B — .wgt classification (real source, lines 663-697) ============
const TIZEN = path.join(__dirname, '../../tizen/js/app.js');
test('B/wgt CRASH: error/rejection -> crashed (socket AND beacon; server dedups)', () => {
const h = harness(TIZEN, 663, 697); h.fire('error', { error: { message: 'boom' } });
assert.equal(h.beacons[0].reason, 'crashed');
assert.equal(h.socketSends[0].reason, 'crashed'); assert.equal(h.socketSends[0].ev, 'device:exit');
});
test('B/wgt NO-MISCLASSIFY: resource error is not a crash', () => {
const h = harness(TIZEN, 663, 697); h.fire('error', { target: { src: 'x.png' } });
assert.equal(h.beacons.length, 0); assert.equal(h.socketSends.length, 0);
});
test('B/wgt CLEAN-CLOSE: pagehide(false) -> clean_exit; BACKGROUNDING pagehide(true) -> NO exit', () => {
let h = harness(TIZEN, 663, 697); h.fire('pagehide', { persisted: false });
assert.equal(h.beacons[0].reason, 'clean_exit');
h = harness(TIZEN, 663, 697); h.fire('pagehide', { persisted: true });
assert.equal(h.beacons.length, 0, 'suspend must NOT emit clean_exit');
assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'no visibilitychange in the exit block');
});
// ============ PART A — server-side socket / #148 / reconnect-vs-exit safety ============
const PORT = 3975; const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-exit3-' + crypto.randomBytes(4).toString('hex'));
let proc, JWT;
before(async () => {
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-exit3.log'), 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false; for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/api/status')).ok) { up = true; break; } } catch {} await sleep(250); }
if (!up) throw new Error('boot fail');
JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@t.local', password: 'test12345', name: 'Op' }) })).json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch {} });
const connect = () => ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
const reg = (s, m) => new Promise((res, rej) => { s.once('device:registered', d => res(d)); s.emit('device:register', m); setTimeout(() => rej(new Error('to')), 5000); });
const pair = (c) => fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: c, name: 't' }) });
const row = async (id) => (await (await fetch(`${BASE}/api/devices/${id}`, { headers: { Authorization: 'Bearer ' + JWT } })).json());
const ackWithin = (s, hb, ms = 1500) => new Promise(r => { let d = false; const f = v => { if (!d) { d = true; r(v); } }; s.once('device:heartbeat-ack', () => f(true)); s.emit('device:heartbeat', hb); setTimeout(() => f(false), ms); });
test('A: crash-emit then teardown -> device goes Offline normally (no orphan/half-open) + reason kept', async () => {
const s = connect(); await new Promise(r => s.on('connect', r));
const d = await reg(s, { pairing_code: '820001', fingerprint: 'f1', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('820001'); await sleep(150);
s.emit('device:exit', { reason: 'crashed', detail: 'boom' }); // emit AS the socket is about to die
s.close(); // teardown immediately after
await sleep(5800);
const r = await row(d.device_id);
assert.equal(r.status, 'offline', 'normal offline transition still fired (teardown not disturbed)');
assert.equal(r.offline_reason, 'crashed');
});
test('A: RECONNECT is NOT an exit, and an exit does not block reconnect — #148 one socket, reason cleared', async () => {
const s1 = connect(); await new Promise(r => s1.on('connect', r));
const d = await reg(s1, { pairing_code: '820002', fingerprint: 'f2', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('820002'); await sleep(150);
s1.emit('device:exit', { reason: 'crashed' }); await sleep(150);
assert.equal((await row(d.device_id)).offline_reason, 'crashed');
s1.close(); await sleep(300);
// genuine reconnect (new socket) — must NOT emit an exit, and must clear the stale reason
const s2 = connect(); await new Promise(r => s2.on('connect', r));
await reg(s2, { device_id: d.device_id, device_token: d.device_token, fingerprint: 'f2', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await sleep(200);
assert.equal((await row(d.device_id)).offline_reason, null, 'reconnect cleared the reason (reconnect != exit)');
assert.equal(await ackWithin(s2, { device_id: d.device_id, telemetry: {} }), true, 'the single reconnected socket is healthy (#148 intact)');
assert.equal((await row(d.device_id)).status, 'online');
s2.close();
});
test('A: VIOLENT kill (abrupt drop, NO device:exit) -> silent, never crashed/clean_exit (Bold-critical)', async () => {
const s = connect(); await new Promise(r => s.on('connect', r));
const d = await reg(s, { pairing_code: '820003', fingerprint: 'f3', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('820003'); await sleep(150);
s.io.engine.close(); // hard transport drop — no clean disconnect, no exit signal (force-stop/power/MDM)
await sleep(5800);
const r = await row(d.device_id);
assert.equal(r.status, 'offline');
assert.equal(r.offline_reason, 'silent', 'external/violent death reads as silent');
assert.notEqual(r.offline_reason, 'clean_exit'); assert.notEqual(r.offline_reason, 'crashed');
});

View file

@ -0,0 +1,95 @@
// Exit-signal contract v1 — manner-of-death annotation on Offline. Server-side proof: the socket
// device:exit handler + the beacon POST endpoint set offline_reason; the Offline transition resolves
// crashed/clean_exit (kept) vs silent (no signal); clear-on-online prevents stale mislabels; honesty
// (client-sent 'silent'/garbage rejected). Client emit paths are proven in Phase 3 per platform.
const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto');
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const ioClient = require('../node_modules/socket.io-client');
const liveness = require('../lib/liveness');
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ===== UNIT: honesty by construction (sanitizeExitReason) =====
test('sanitizeExitReason: only crashed/clean_exit accepted; silent + unknown REJECTED (-> server silent)', () => {
assert.equal(liveness.sanitizeExitReason('crashed', 'boom').reason, 'crashed');
assert.equal(liveness.sanitizeExitReason('clean_exit', null).reason, 'clean_exit');
assert.equal(liveness.sanitizeExitReason('silent'), null); // server-inferred only — never from a client
assert.equal(liveness.sanitizeExitReason('exploded'), null); // never fabricate an unknown category
assert.equal(liveness.sanitizeExitReason(''), null);
assert.equal(liveness.sanitizeExitReason('crashed', 'x'.repeat(500)).detail.length, 200); // capped
assert.equal(liveness.sanitizeExitReason('crashed', ' ').detail, null); // blank -> null
});
// ===== E2E =====
const PORT = 3974; const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-exit-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-exit.log');
let proc, JWT;
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false; for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/api/status')).ok) { up = true; break; } } catch { /* */ } await sleep(250); }
if (!up) throw new Error('boot fail:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@t.local', password: 'test12345', name: 'Op' }) })).json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
const connect = () => ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
const registerOn = (s, msg) => new Promise((res, rej) => { s.once('device:registered', d => res(d)); s.emit('device:register', msg); setTimeout(() => rej(new Error('reg timeout')), 5000); });
const pair = (code) => fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: code, name: 't' }) });
const row = async (id) => (await (await fetch(`${BASE}/api/devices/${id}`, { headers: { Authorization: 'Bearer ' + JWT } })).json());
async function provisionPaired(code, ident = {}) {
const s = connect(); await new Promise(r => s.on('connect', r));
const reg = await registerOn(s, { pairing_code: code, fingerprint: 'fp' + code, device_info: {}, ...ident });
await pair(code); await sleep(150);
return { s, id: reg.device_id, token: reg.device_token };
}
// (4 devices — the SELF_HOSTED plan caps at 5; offline devices still count.)
test('crashed: device:exit sets offline_reason, and it SURVIVES the Offline transition (COALESCE)', async () => {
const d = await provisionPaired('810001', { client_type: 'apk', contract_version: 'v4' });
d.s.emit('device:exit', { reason: 'crashed', detail: 'NullPointerException: boom' });
await sleep(250);
assert.equal((await row(d.id)).offline_reason, 'crashed', 'set immediately on device:exit');
d.s.close(); await sleep(5800); // OFFLINE_DEBOUNCE_MS=5000
const r = await row(d.id);
assert.equal(r.status, 'offline'); assert.equal(r.offline_reason, 'crashed', 'kept through Offline (not overwritten by silent)');
});
test('clean_exit + clear-on-online: reason set, then CLEARED on re-register (no stale mislabel)', async () => {
const d = await provisionPaired('810002', { client_type: 'wgt', contract_version: 'v4' });
d.s.emit('device:exit', { reason: 'clean_exit', detail: 'onDestroy' }); await sleep(250);
assert.equal((await row(d.id)).offline_reason, 'clean_exit');
d.s.close(); await sleep(300);
const s2 = connect(); await new Promise(r => s2.on('connect', r)); // reconnect = fresh session
await registerOn(s2, { device_id: d.id, device_token: d.token, fingerprint: 'fp810002', device_info: {}, client_type: 'wgt', contract_version: 'v4' });
await sleep(200);
assert.equal((await row(d.id)).offline_reason, null, 'cleared on (re)online — a later death starts fresh');
s2.close();
});
test('silent + honesty: client-sent silent/garbage REJECTED, then Offline-with-no-signal -> server silent', async () => {
const d = await provisionPaired('810003', { client_type: 'apk', contract_version: 'v4' }); // also the old-client / no-signal case
d.s.emit('device:exit', { reason: 'silent' }); // client must NOT be able to assert silent
d.s.emit('device:exit', { reason: 'kaboom' }); // unknown -> rejected, never fabricated
await sleep(300);
assert.equal((await row(d.id)).offline_reason, null, 'neither client value was accepted');
d.s.close(); await sleep(5800);
const r = await row(d.id);
assert.equal(r.status, 'offline'); assert.equal(r.offline_reason, 'silent', 'server infers silent on Offline (correct)');
});
test('beacon endpoint: valid token sets reason; bad token is a silent 204 no-op', async () => {
const d = await provisionPaired('810004', { client_type: 'player', contract_version: 'v4' });
const post = (body) => fetch(BASE + '/api/device/exit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
let res = await post({ device_id: d.id, device_token: 'WRONG', reason: 'crashed' });
assert.equal(res.status, 204);
assert.equal((await row(d.id)).offline_reason, null, 'bad token did NOT set a reason (no oracle, no write)');
res = await post({ device_id: d.id, device_token: d.token, reason: 'clean_exit', detail: 'pagehide' });
assert.equal(res.status, 204); await sleep(150);
assert.equal((await row(d.id)).offline_reason, 'clean_exit', 'valid-token beacon set the reason');
d.s.close();
});

View file

@ -5,6 +5,7 @@ const fs = require('fs');
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
const config = require('../config');
const heartbeat = require('../services/heartbeat');
const liveness = require('../lib/liveness'); // v4 core pass: pure ack/liveness/identity helpers
const commandQueue = require('../lib/command-queue');
const reconnectThrottle = require('../lib/reconnect-throttle');
const contentAckLimiter = require('../lib/content-ack-limiter');
@ -15,6 +16,7 @@ const sessionSettle = require('../lib/session-settle'); // #148 patch2: evicti
const { resolveIdentity } = require('../lib/device-identity');
const logCoalescer = require('../lib/log-coalescer');
const loopLag = require('../services/loop-lag');
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
// Debounce window for marking a device offline on socket disconnect. Brief
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
@ -253,6 +255,25 @@ function checkDeviceAccess(deviceId) {
return { allowed: true };
}
// v4 core-pass helpers (module scope; db is a ready singleton at require time).
const _deviceExistsStmt = db.prepare('SELECT 1 FROM devices WHERE id = ?');
function deviceExists(id) { return !!(id && _deviceExistsStmt.get(id)); }
const _identityReadStmt = db.prepare('SELECT client_type, client_version, platform, contract_version FROM devices WHERE id = ?');
const _persistIdentityStmt = db.prepare('UPDATE devices SET client_type = ?, client_version = ?, platform = ?, contract_version = ? WHERE id = ?');
function persistIdentity(deviceId, data) {
if (!deviceId) return;
// FIX 3: capture-don't-act; degrades to legacy/unknown for old clients; NEVER breaks register.
// A1 change-detection: only WRITE when the identity actually changed vs stored. A genuine
// reconnect with unchanged identity (the common case, incl. flapping / re-pair churn) does a cheap
// read and NO write — no UPDATE, no WAL churn. First provision (stored NULLs) and a real change
// (e.g. new client_version after an OTA) still write.
try {
const i = liveness.captureIdentity(data);
if (!liveness.identityChanged(_identityReadStmt.get(deviceId), i)) return; // unchanged — skip the write
_persistIdentityStmt.run(i.client_type, i.client_version, i.platform, i.contract_version, deviceId);
} catch (e) { /* identity capture must never break registration */ }
}
module.exports = function setupDeviceSocket(io) {
// Expose helpers for use by route handlers
module.exports.lastScreenshots = lastScreenshots;
@ -396,7 +417,7 @@ module.exports = function setupDeviceSocket(io) {
pendingOfflines.delete(existing.device_id);
}
evictPriorSocket(existing.device_id, socket.id);
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now'), offline_reason = NULL, offline_reason_at = NULL, offline_detail = NULL WHERE id = ?")
.run(getClientIp(socket), existing.device_id);
socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
// If device was already claimed by a user, tell the player it's paired
@ -409,9 +430,11 @@ module.exports = function setupDeviceSocket(io) {
}
currentDeviceId = existing.device_id;
heartbeat.registerConnection(existing.device_id, socket.id);
heartbeat.recordReconnect(existing.device_id); // FIX 2: churn signal
persistIdentity(existing.device_id, data); // FIX 3: identity capture
socket.join(existing.device_id);
logDeviceStatus(existing.device_id, 'online');
emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online' });
emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online', liveness: heartbeat.livenessFor(existing.device_id) });
// Flush any commands/playlist-updates queued while this device was offline.
commandQueue.flushQueue(deviceNs, existing.device_id, buildPlaylistPayload);
// Send playlist
@ -508,7 +531,7 @@ module.exports = function setupDeviceSocket(io) {
}
evictPriorSocket(device_id, socket.id);
sessionSettle.accepted(device_id); // #148 patch2: (re)arm the settle window on an accepted connection
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now'), offline_reason = NULL, offline_reason_at = NULL, offline_detail = NULL WHERE id = ?")
.run(getClientIp(socket), device_id);
// #143: past the validateDeviceToken gate above the stored token is
@ -526,6 +549,14 @@ module.exports = function setupDeviceSocket(io) {
}
heartbeat.registerConnection(device_id, socket.id);
// #134: a same-socket re-register is a playlist REFRESH (~45-60s), NOT a reconnect and NOT
// a new identity. Match the existing !isPlaylistRefresh gates (:486/:605): don't count it as
// churn (A2 — else healthy refreshers cross DEGRADED_RECONNECTS and show Degraded) and don't
// re-write identity (A1 — else a sync UPDATE + WAL churn every ~45-60s per device).
if (!isPlaylistRefresh) {
heartbeat.recordReconnect(device_id); // genuine reconnect only
persistIdentity(device_id, data); // change-detected write (see persistIdentity)
}
socket.join(device_id);
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
// #143: a device paired/claimed server-side (user_id set) that RECONNECTS must be told
@ -632,7 +663,22 @@ module.exports = function setupDeviceSocket(io) {
currentDeviceId = id;
authenticated = true;
// #150: relink the fingerprint to the NEW device row (the fingerprint block above
// leaves device_id NULL on a post-delete re-pair) so the settings key is reliable,
// then restore any settings this physical device had at its last deletion —
// orientation/name/playlist/etc come back automatically instead of resetting. Runs
// BEFORE the dashboard:device-added emit below so that emit carries restored values.
if (fingerprint) {
try {
db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen) VALUES (?, ?, strftime('%s','now')) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen")
.run(fingerprint, id);
const restored = deviceSettings.applyToDevice(id, fingerprint);
if (restored) console.log(`[#150] restored saved settings for re-paired device ${id} (fp ${fingerprint.slice(0, 8)}…)`);
} catch (e) { console.warn(`[#150] settings restore failed for ${id}: ${e.message}`); }
}
heartbeat.registerConnection(id, socket.id);
persistIdentity(id, data); // FIX 3: capture v4 identity on first provision (degrades for old clients)
socket.join(id);
socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });
@ -657,8 +703,16 @@ module.exports = function setupDeviceSocket(io) {
// Heartbeat with telemetry
socket.on('device:heartbeat', (data) => {
const { device_id, telemetry } = data || {};
// v4 PRIMARY + FIX 1 — UNIFORM ACK. Emitted from THIS single shared handler for every client
// type (APK / .wgt / /player hit the same handler = uniform by construction), and BEFORE the
// auth guard so a KNOWN device's watchdog stays armed even mid-reconnect (before this socket
// finishes re-registering). Anonymous / never-authenticated sockets are NOT acked (degrade-safe
// covers them). Old clients simply ignore the ack — harmless.
if (liveness.ackableHeartbeat(currentDeviceId, device_id, deviceExists)) {
socket.emit('device:heartbeat-ack', {}); // cheap, to the emitting socket only
}
if (!requireDeviceAuth()) return;
const { device_id, telemetry } = data;
if (!device_id || device_id !== currentDeviceId) return;
currentDeviceId = device_id;
@ -697,6 +751,7 @@ module.exports = function setupDeviceSocket(io) {
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', {
device_id,
status: 'online',
liveness: heartbeat.livenessFor(device_id), // FIX 2: server-derived 3-state (healthy/degraded/offline)
telemetry
});
}
@ -785,6 +840,21 @@ module.exports = function setupDeviceSocket(io) {
.run(ota_status ?? 'none', ota_target_version ?? null, ota_attempts ?? 0, device_id);
});
// Exit-signal contract v1 — the device's best-effort "last gasp": it announces its manner of death
// (crashed | clean_exit) as (usually) its final act. We record it; when the device then goes Offline
// the annotation is applied (else 'silent'). ADDITIVE — never touches offline detection. Cleared on
// (re)online (the register UPDATEs) so a stale reason can't mislabel a later death. The same canonical
// shape also arrives via the beacon POST /api/device/exit for reliable-on-unload delivery.
socket.on('device:exit', (data) => {
if (!requireDeviceAuth() || !currentDeviceId) return;
const { device_id, reason, detail } = data || {};
if (device_id && device_id !== currentDeviceId) return; // forged/mismatched -> no-op
const e = liveness.sanitizeExitReason(reason, detail); // unknown -> null -> falls to 'silent'
if (!e) return;
db.prepare("UPDATE devices SET offline_reason = ?, offline_reason_at = strftime('%s','now'), offline_detail = ? WHERE id = ?")
.run(e.reason, e.detail, currentDeviceId);
});
// Play event logging (proof-of-play)
socket.on('device:play-event', (data) => {
if (!requireDeviceAuth()) return;
@ -908,10 +978,14 @@ module.exports = function setupDeviceSocket(io) {
const activeNow = heartbeat.getConnection(deviceId);
if (activeNow && activeNow.socketId !== closingSocketId) return;
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?").run(deviceId);
// Exit-signal contract: resolve manner-of-death. If the device announced a reason before dying
// (offline_reason non-NULL, set by device:exit/beacon this session), keep it; else -> 'silent'
// (no signal arrived). COALESCE makes this a pure annotation — offline detection is unchanged.
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now'), offline_reason = COALESCE(offline_reason, 'silent'), offline_reason_at = COALESCE(offline_reason_at, strftime('%s','now')) WHERE id = ?").run(deviceId);
heartbeat.removeConnection(deviceId);
logDeviceStatus(deviceId, 'offline');
emitToDeviceWorkspace(dashboardNs, deviceId, 'dashboard:device-status', { device_id: deviceId, status: 'offline' });
const _off = db.prepare("SELECT offline_reason, offline_detail, client_type FROM devices WHERE id = ?").get(deviceId) || {};
emitToDeviceWorkspace(dashboardNs, deviceId, 'dashboard:device-status', { device_id: deviceId, status: 'offline', liveness: 'offline', offline_reason: _off.offline_reason || 'silent', offline_detail: _off.offline_detail || null, client_type: _off.client_type || null });
// If this device was leading a wall, reassign leadership to the next
// online member so playback stays driven.

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
id="http://screentinker.com/player" version="1.9.2" viewmodes="maximized">
id="http://screentinker.com/player" version="1.9.3" viewmodes="maximized">
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
<tizen:profile name="tv"/>
<name>ScreenTinker</name>

View file

@ -15,7 +15,7 @@
// packaged config.xml via the Tizen application API; fall back to a constant that
// build-wgt.sh stamps from config.xml's version="" so the dashboard always shows the
// version that is actually installed (never the old hardcoded '1.0.0').
var APP_VERSION_FALLBACK = '1.9.1'; // st:app-version — stamped by build-wgt.sh
var APP_VERSION_FALLBACK = '1.9.2'; // st:app-version — stamped by build-wgt.sh
var APP_VERSION = (function () {
try {
var v = tizen.application.getCurrentApplication().appInfo.version;
@ -32,7 +32,8 @@
id: 'st_device_id',
token: 'st_device_token',
fp: 'st_fingerprint',
code: 'st_pairing_code'
code: 'st_pairing_code',
payload: 'st_payload_cache' // A2: last renderable playlist-update, replayed on cold-start/offline
};
// ---- persistent state ----
@ -85,6 +86,109 @@
try { if (window.webapis && webapis.appcommon) webapis.appcommon.setScreenSaver(webapis.appcommon.AppCommonScreenSaverState.SCREEN_SAVER_OFF); } catch (e) {}
}
// A5 — MONOTONIC clock for lifecycle time deltas (watchdog silence, resume hidden-duration), so an
// NTP/RTC wall-clock step on a 24/7 TV can't false-fire (forward jump) or blind (backward jump) the
// watchdog. Date.now() is kept ONLY where a real wall clock is needed (telemetry, cross-device wall sync).
var mono = (typeof performance !== 'undefined' && performance.now)
? function () { return performance.now(); }
: function () { return Date.now(); };
// FIX A — RE-ASSERT keep-awake on an interval. tizen.power.request / the screensaver-off
// setting can be released when the TV backgrounds/suspends the app, and the player had no
// way to re-suppress it (keepAwake was only called at boot/connect/command). ~30s is well
// under any TV screensaver timeout and the calls are cheap best-effort no-ops. Cleared by
// stopKeepAwake() on app teardown.
var keepAwakeTimer = null;
function startKeepAwake() {
stopKeepAwake();
keepAwake();
keepAwakeTimer = setInterval(keepAwake, 30000);
}
function stopKeepAwake() { if (keepAwakeTimer) { clearInterval(keepAwakeTimer); keepAwakeTimer = null; } }
// FIX B — VISIBILITY / RESUME handling. On a TV, a background/suspend can (a) release
// keep-awake and (b) silently drop the socket, leaving it HALF-OPEN — socket.connected stays
// true while the transport is dead, which socket.io CANNOT detect, so it won't auto-reconnect.
//
// Double-connect discipline (the one way this could reintroduce #148's duplicate socket):
// - DEFER to socket.io when the socket is already disconnected (socket.io owns that
// reconnect, and #118 re-registers on 'connect').
// - OWN a clean teardown-before-reopen (via connect(), which disconnects the old socket
// FIRST — cancelling any socket.io reconnect — then opens exactly ONE new socket) ONLY
// for the half-open case socket.io can't see.
// These are mutually-exclusive socket states (connected vs not), so a manual reconnect
// never races socket.io's auto-reconnect. We do NOT manually re-register (connect's 'connect'
// handler does, once). Half-open is inferred from how long the app was hidden — socket.connected
// alone is unreliable post-suspend and there is no server ack channel to actively probe
// without a server change (out of scope for this client-only build).
var hiddenAtMs = 0;
var SUSPEND_HIDE_MS = 3000; // hidden >= this ≈ an OS suspend that can half-open the socket
// Pure decision, factored out so the double-connect logic is unit-testable:
// 'reconnect' = half-open -> own teardown+reopen ; 'defer' = already down -> socket.io owns it ; 'noop'
function resumeDecision(hasSocket, socketConnected, hiddenMs) {
if (!hasSocket) return 'noop';
if (!socketConnected) return 'defer';
return (hiddenMs >= SUSPEND_HIDE_MS) ? 'reconnect' : 'noop';
}
function onVisibility() {
if (document.visibilityState === 'hidden' || document.hidden) { hiddenAtMs = mono(); return; } // A5: monotonic
keepAwake(); // re-assert immediately on resume
var hiddenMs = hiddenAtMs ? (mono() - hiddenAtMs) : 0; // A5: monotonic hidden-duration
hiddenAtMs = 0;
var action = resumeDecision(!!socket, !!(socket && socket.connected), hiddenMs);
if (action === 'reconnect') connect(); // teardown-before-reopen -> exactly one socket; #118 registers once
// 'defer' -> socket.io auto-reconnects (re-registers on 'connect'); 'noop' -> healthy, do nothing
}
if (typeof window !== 'undefined') window.__stResumeDecision = resumeDecision; // test hook (inert in prod)
// FIX B (hardened) — application-level LIVENESS WATCHDOG. The resume path above only fires on
// visibilitychange, so a socket that goes half-open with NO visibility event (network drop, NAT
// idle timeout, transport death while foregrounded) would never be caught: socket.connected stays
// true on a dead socket and socket.io won't reconnect. The watchdog watches for server SILENCE.
// The server sends an engine ping every ~15s (config.pingInterval) AND app events, so a healthy
// socket refreshes lastServerMsgAt at least every ~15s (markAlive is wired into a central receive
// path in connect(): socket.onAny + socket.io 'ping'). If the socket goes quiet past the liveness
// window while we still believe we're connected + authenticated, it is half-open -> clean
// teardown-before-reopen via connect() (exactly one socket; #118 re-registers once).
//
// Double-connect discipline: the watchdog fires ONLY while socket.connected===true (the half-open
// state socket.io cannot see) — socket.io's own auto-reconnect only runs when socket.connected is
// false, so the two never overlap. connect() is teardown-first, and it resets lastServerMsgAt, so
// the watchdog and the resume fast-path can't double-fire a second reconnect. Client-only: uses
// signals the server already sends; no server change.
var lastServerMsgAt = 0;
var livenessConfirmed = false; // v4 degrade-safe: DON'T arm until a device:heartbeat-ack
// v4 canonical anti-herd THRESHOLD: 45s ± up to 10s random jitter (was a fixed 35s), so a fleet
// doesn't all declare half-open simultaneously under a shared cause (server load delaying acks
// fleet-wide). Matches the APK's LivenessWatchdog.thresholdMs so the three clients behave
// identically on the wire. Re-jittered per connect().
var THRESHOLD_BASE_MS = 45000, THRESHOLD_JITTER_MS = 10000;
function thresholdMs(rand) { return THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * THRESHOLD_JITTER_MS); }
var livenessWindowMs = THRESHOLD_BASE_MS;
var watchdogTimer = null;
// v4: ANY inbound refreshes the SILENCE timestamp (so other server traffic keeps a healthy socket
// alive) — but it does NOT arm. Arming gates on the ack specifically (see the device:heartbeat-ack
// handler), so engine pings alone can't arm us against an ack-less server.
function markAlive() { lastServerMsgAt = mono(); } // A5 monotonic
// Pure, unit-testable. Reconnect ONLY when a connected+authenticated socket whose liveness we have
// ARMED (seen >=1 device:heartbeat-ack — v4 degrade-safe: a server that never app-acks never arms
// us, so no false-fire even though engine pings keep flowing) has gone silent past the window.
function watchdogShouldReconnect(hasSocket, connected, authed, confirmed, silentMs, windowMs) {
return !!(hasSocket && connected && authed && confirmed && silentMs > windowMs);
}
function startWatchdog() {
stopWatchdog();
watchdogTimer = setInterval(function () {
var silentMs = lastServerMsgAt ? (mono() - lastServerMsgAt) : 0; // A5 monotonic
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), authenticated, livenessConfirmed, silentMs, livenessWindowMs)) {
connect(); // half-open backstop: teardown-first -> one socket, #118 re-registers once
}
}, 10000);
}
function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } }
if (typeof window !== 'undefined') { window.__stWatchdogShouldReconnect = watchdogShouldReconnect; window.__stThresholdMs = thresholdMs; }
// ---- networking ----
var socket = null;
var deviceId = get(LS.id);
@ -125,16 +229,29 @@
if (!serverUrl) { show(elSetup); return; }
keepAwake();
if (socket) { try { socket.disconnect(); } catch (e) {} socket = null; }
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; } // H4: a fresh connect supersedes any pending re-register
var base = serverUrl.replace(/\/+$/, '');
socket = io(base + '/device', {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000,
timeout: 10000
reconnectionDelay: 1000, // v4 canonical: 1s start (was 2s)
reconnectionDelayMax: 30000, // v4 canonical: 30s cap, within the ~30-60s band (was 10s)
randomizationFactor: 0.2, // v4 canonical: ±20% jitter (was ±50%); exponential-double shape kept
timeout: 20000 // cheap parity (GAP4c): match /player + APK; 10s prematurely errored slow TV WebKit / WS-blocked networks
});
// FIX B (hardened): central receive-path liveness. A fresh socket is assumed alive; then EVERY
// inbound server message refreshes lastServerMsgAt — app events via onAny, and the engine ping
// (~15s) via the manager 'ping'. This resets liveness so the watchdog / resume fast-path can't
// double-fire, and feeds the watchdog's server-silence detection. (io() returns a fresh socket
// per connect — verified — so these listeners don't accumulate.)
lastServerMsgAt = mono(); // A5 monotonic
livenessConfirmed = false; // v4 degrade-safe: DIS-arm until a heartbeat-ack re-arms
livenessWindowMs = thresholdMs(Math.random()); // v4: fresh 45s ± up to 10s jitter for this connection
socket.onAny(markAlive); // refresh SILENCE on any inbound (does not arm)
socket.io.on('ping', markAlive); // engine ping refreshes silence too (still does not arm)
socket.on('connect', function () {
// #118: a brand-new socket is not authenticated until device:registered. Reset the
// flag and kill any heartbeat carried over from the previous socket, so a beat can't
@ -172,14 +289,23 @@
if (data.status === 'provisioning') showPairing();
});
// v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack.
// A server that sends engine pings but no app-ack (old/pre-contract server) never arms us, so the
// watchdog can't false-fire — markAlive (onAny) still refreshed lastServerMsgAt for the silence
// check, but ARMING is gated on the ack specifically.
socket.on('device:heartbeat-ack', function () { livenessConfirmed = true; });
socket.on('device:paired', function () {
del(LS.code); clearToast(); show(elStage);
});
socket.on('device:unpaired', function () {
del(LS.id); del(LS.token); del(LS.code);
del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
deviceId = null; deviceToken = null;
register(); // re-register fresh -> new pairing code
// FIX F — back off 3s before re-registering, symmetric with the auth-error path below,
// so a repeatedly-unpaired device (e.g. MDM re-pair churn) can't tight-loop
// register -> unpaired -> register.
scheduleRegister(3000);
});
socket.on('device:auth-error', function (data) {
@ -190,9 +316,9 @@
stopHeartbeat();
toast((data && data.error) ? data.error : 'Auth error', false);
// Bad/stale token or fingerprint-reclaim block: drop creds and re-pair.
del(LS.id); del(LS.token);
del(LS.id); del(LS.token); del(LS.payload); // A2: clear cached content when identity is lost
deviceId = null; deviceToken = null;
setTimeout(register, 3000);
scheduleRegister(3000);
});
socket.on('device:playlist-update', onPlaylist);
@ -252,6 +378,12 @@
function register() {
var msg = { device_info: deviceInfo(), fingerprint: fingerprint() };
// v4 client identity block — additive, canonical snake_case (same field shape as the APK, so the
// server consumes one thing). Backward-compatible: an old server ignores unknown fields.
msg.client_type = 'wgt';
msg.client_version = APP_VERSION; // config.xml version (stamped by build-wgt.sh)
msg.platform = 'Tizen ' + (tizenVersion() || '');
msg.contract_version = 'v4';
if (deviceId && deviceToken) { msg.device_id = deviceId; msg.device_token = deviceToken; }
else { msg.pairing_code = pairingCode(); }
socket.emit('device:register', msg);
@ -269,8 +401,11 @@
// requireDeviceAuth() rejects the beat with device:auth-error.
if (!socket || !socket.connected || !deviceId || !authenticated) return;
socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
// Every 4th beat (~60s) ask for a fresh playlist, matching the Android player.
if ((++beatCount % 4) === 0) socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
// FIX C — every 4th beat (~60s) ask for a fresh playlist by re-emitting device:register;
// the server responds with a fresh device:playlist-update (deviceSocket.js). This was
// previously a duplicate device:heartbeat (comment != code), so the .wgt had NO working
// fallback refresh and relied entirely on server push. Matches the Android player.
if ((++beatCount % 4) === 0) register();
}, HEARTBEAT_MS);
}
function stopHeartbeat() {
@ -320,6 +455,12 @@
? STDeviceControl.capabilities() : { backend: 'none', reboot: false, panel: false };
reportCmd('info', 'capabilities',
'fleet control backend=' + caps.backend + ' reboot=' + caps.reboot + ' panel=' + caps.panel);
// A3 observability: the keep-awake fix only actually holds the screen if these APIs resolve on the
// TV's firmware/signing path. Surface their presence to the dashboard log so Bold can VERIFY on real
// hardware whether keep-awake is real (vs a silent no-op) — the load-bearing check for the flap fix.
var ka = 'keep-awake: setScreenSaver=' + !!(window.webapis && webapis.appcommon)
+ ' tizen.power=' + !!(window.tizen && tizen.power);
reportCmd('info', 'keepawake', ka);
} catch (e) {}
}
@ -361,6 +502,25 @@
function startStreaming() { stopStreaming(); streamTimer = setInterval(captureAndSend, 1000); }
function stopStreaming() { if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } }
// H4 (teardown hygiene): TRACK the register re-try so a reset/reconnect can cancel a pending late
// register (Lens 2 found it untracked -> a stray register could fire on a fresh socket).
var registerTimer = null;
function scheduleRegister(delay) {
if (registerTimer) clearTimeout(registerTimer);
registerTimer = setTimeout(function () { registerTimer = null; register(); }, delay);
}
// H4: stop the per-SESSION timers/loops when leaving playback (reset / BACK-to-setup). Otherwise the
// player loop keeps firing on the hidden stage and throws (serverUrl=null), heartbeat/stream keep
// running, and a pending register can fire late. Keep-awake + the watchdog are LIFETIME timers
// (guarded no-ops while off-session) and are intentionally left running. Idempotent.
function teardownSession() {
stopHeartbeat();
stopStreaming();
try { player.stop(); } catch (e) {}
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; }
authenticated = false;
}
// ---- playback ----
var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
// Multi-zone layout renderer (matches the Android player). app.js picks the renderer
@ -418,6 +578,9 @@
show(elStage);
return;
}
// A2: cache the last RENDERABLE payload so a reboot / WS-outage with no connectivity replays it
// instead of showing the idle card. Only non-suspended payloads are cached.
try { set(LS.payload, JSON.stringify(payload)); } catch (e) {}
// If we have content + we're paired, make sure we're on the stage.
if (elPairing.classList.contains('hidden') === false) show(elStage);
else if (elStage.classList.contains('hidden')) show(elStage);
@ -435,7 +598,7 @@
wallController.exit(); // leave wall mode if we were in it
applyOrientation(payload.orientation || 'landscape');
var layout = payload.layout;
if (layout && layout.zones && layout.zones.length) {
if (layout && Array.isArray(layout.zones) && layout.zones.length) { // B3: non-array zones would throw in zoneRenderer
// Multi-zone layout (matches the Android player). Leave single-zone mode first.
player.stop();
zoneRenderer.setTimezone(payload.timezone || null); // #74/#75: effective tz
@ -464,9 +627,10 @@
connect();
}
elReset.addEventListener('click', function () {
del(LS.url); del(LS.id); del(LS.token); del(LS.code);
del(LS.url); del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
deviceId = null; deviceToken = null; serverUrl = null;
if (socket) { try { socket.disconnect(); } catch (e) {} }
teardownSession(); // H4: stop heartbeat/stream/player-loop + pending register (no dangling timers on setup)
show(elSetup);
});
@ -475,9 +639,12 @@
document.addEventListener('keydown', function (e) {
if (e.keyCode === 10009) { // Samsung RETURN / BACK
if (!elSetup.classList.contains('hidden')) {
stopKeepAwake(); stopWatchdog(); // FIX A/B: clear timers cleanly before the app exits
sendExitSignal('clean_exit', 'back_key'); // exit-signal: operator BACK-key exit = confident clean_exit
try { tizen.application.getCurrentApplication().exit(); } catch (x) {}
} else {
if (socket) { try { socket.disconnect(); } catch (x) {} }
teardownSession(); // H4: same clean teardown when BACK returns to setup
elUrl.value = serverUrl || '';
elSetupStatus.textContent = ''; elSetupStatus.className = 'status';
show(elSetup); elUrl.focus();
@ -489,9 +656,52 @@
// Always reach the server prompt until the display is actually paired. Only a
// fully provisioned device (has a saved device_id + token) goes straight to
// playback; otherwise show the setup screen and ask for / confirm the server.
keepAwake();
startKeepAwake(); // FIX A: assert + re-assert keep-awake on an interval
document.addEventListener('visibilitychange', onVisibility); // FIX B: suspend/resume fast-path
startWatchdog(); // FIX B (hardened): server-silence liveness backstop
// Exit-signal contract v1 — best-effort last gasp. crashed: window.onerror / unhandledrejection.
// clean_exit: operator BACK-key exit (below) + pagehide(persisted=false, a real unload not a bfcache
// suspend). Sends over BOTH the live socket (reliable when still connected, e.g. BACK-key / in-app
// crash) AND navigator.sendBeacon (reliable-on-unload — Chromium webview); the server dedups. Honesty:
// only these two confident categories; uncertain -> nothing -> server infers 'silent'. A Tizen system/
// launcher terminate fires NO hook here -> correctly falls to 'silent'. Idempotent (first wins).
var __exitSent = false;
function sendExitSignal(reason, detail) {
try {
if (__exitSent) return;
if (reason !== 'crashed' && reason !== 'clean_exit') return;
if (!deviceId || !deviceToken || !serverUrl) return; // unpaired -> nothing to attribute
__exitSent = true;
var d = (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined;
if (socket && socket.connected) { try { socket.emit('device:exit', { device_id: deviceId, reason: reason, detail: d }); } catch (e) {} }
if (navigator.sendBeacon) {
var body = JSON.stringify({ device_id: deviceId, device_token: deviceToken, reason: reason, detail: d });
navigator.sendBeacon(serverUrl.replace(/\/+$/, '') + '/api/device/exit', new Blob([body], { type: 'application/json' }));
}
} catch (e) { /* a dying app must never throw */ }
}
window.addEventListener('error', function (ev) {
if (!ev) return;
var isResourceError = ev.target && ev.target !== window && (ev.target.src || ev.target.href); // img/script load fail is NOT a crash
if (isResourceError) return;
sendExitSignal('crashed', (ev.error && ev.error.message) || ev.message || 'error');
});
window.addEventListener('unhandledrejection', function (ev) {
var r = ev && ev.reason;
sendExitSignal('crashed', (r && (r.message || String(r))) || 'unhandledrejection');
});
window.addEventListener('pagehide', function (ev) {
if (ev && ev.persisted) return; // bfcache suspend (may restore) — NOT a death; the watchdog owns it
sendExitSignal('clean_exit', 'pagehide');
});
if (serverUrl && deviceId && deviceToken) {
show(elStage); connect(); // paired — reconnect to playback
// A2: render cached content IMMEDIATELY so a cold-start/offline TV isn't blank while the socket
// connects (or if it can't). The socket's fresh device:playlist-update replaces it on connect.
show(elStage);
var _cp = get(LS.payload);
if (_cp) { try { onPlaylist(JSON.parse(_cp)); } catch (e) {} }
connect(); // paired — reconnect to playback
} else if (serverUrl) {
show(elSetup); elUrl.value = serverUrl; // server known, not paired — confirm + connect
elSetupStatus.className = 'status';

View file

@ -34,7 +34,9 @@ function PlaylistPlayer(stageEl, getBase) {
}
PlaylistPlayer.prototype.load = function (assignments) {
var items = (assignments || []).filter(function (a) {
// B3: a malformed device:playlist-update with a non-array `assignments` used to throw
// (.filter is not a function) out of the socket handler; coerce to [] instead.
var items = (Array.isArray(assignments) ? assignments : []).filter(function (a) {
return a && (a.content_id || a.widget_id || a.remote_url);
});
// Stable order
@ -72,7 +74,10 @@ PlaylistPlayer.prototype.idle = function () {
};
PlaylistPlayer.prototype.durationMs = function (item) {
var d = item.duration_sec || this.DEFAULT_DURATION;
// B3: a non-numeric duration_sec ("abc") used to yield NaN -> schedule(NaN) -> fire-ASAP spin.
// Coerce; any non-positive/NaN falls back to the default.
var d = Number(item.duration_sec);
if (!(d > 0)) d = this.DEFAULT_DURATION;
if (d < this.MIN_DURATION) d = this.MIN_DURATION;
return d * 1000;
};
@ -203,7 +208,14 @@ PlaylistPlayer.prototype.playCurrent = function () {
// Give a broken item ~2s then move on so the loop never wedges.
PlaylistPlayer.prototype.skipSoon = function () {
if (this.items.length > 1) this.schedule(2000);
if (this.items.length > 1) { this.schedule(2000); return; }
// A1: a SINGLE-item playlist used to WEDGE on a broken item — skipSoon did nothing, so a transient
// failure (CDN blip, brief network loss, a 404 that later resolves) left a permanent black screen
// while the heartbeat still reported the device online. Retry the SAME item after a backoff so it
// self-heals instead of going dark forever.
var self = this;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(function () { self.playCurrent(); }, 5000);
};
PlaylistPlayer.prototype.fit = function (el, item) {
@ -239,7 +251,7 @@ PlaylistPlayer.prototype.renderVideo = function (item, single) {
// Safety net: if 'ended' never fires (rare), advance after the known
// content duration (or the assignment duration) + a buffer.
if (!single) {
var secs = item.content_duration || item.duration_sec || this.DEFAULT_DURATION;
var secs = Number(item.content_duration || item.duration_sec) || this.DEFAULT_DURATION; // B3: numeric
this.schedule((secs + 5) * 1000);
}
};