mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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:
commit
d474122334
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ~30–60s 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 app’s 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 platform’s 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',
|
||||
|
|
|
|||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
· ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')}
|
||||
· ${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))} · ${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) {
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
124
server/lib/device-settings.js
Normal file
124
server/lib/device-settings.js
Normal 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
81
server/lib/liveness.js
Normal 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 };
|
||||
|
|
@ -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`) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
192
server/test/156-schedule-read-path.test.js
Normal file
192
server/test/156-schedule-read-path.test.js
Normal 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');
|
||||
});
|
||||
58
server/test/ota-breaker-patchn.test.js
Normal file
58
server/test/ota-breaker-patchn.test.js
Normal 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');
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
190
server/test/v4-core-liveness.test.js
Normal file
190
server/test/v4-core-liveness.test.js
Normal 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();
|
||||
});
|
||||
99
server/test/v4-core-refresh-gate.test.js
Normal file
99
server/test/v4-core-refresh-gate.test.js
Normal 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();
|
||||
});
|
||||
129
server/test/v4-exit-signal-phase3.test.js
Normal file
129
server/test/v4-exit-signal-phase3.test.js
Normal 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');
|
||||
});
|
||||
95
server/test/v4-exit-signal.test.js
Normal file
95
server/test/v4-exit-signal.test.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
240
tizen/js/app.js
240
tizen/js/app.js
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue