mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Merge fix/qa-sweep: 25 fixes from the platform QA audit
This commit is contained in:
commit
d51138624e
|
|
@ -43,6 +43,14 @@ android {
|
|||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
// ScheduleEval uses java.time (Instant/LocalDate/ZoneId), which is API 26 — but minSdk is
|
||||
// 24. Without desugaring, per-item dayparting/expiry threw NoClassDefFoundError on Android
|
||||
// 7.0/7.1, which are still common on cheap signage sticks and older TV boxes. Because that
|
||||
// is an Error and not an Exception, the evaluator's deliberate fail-open guard did not
|
||||
// catch it: the playlist update aborted before content downloaded, and the cold-start path
|
||||
// then cleared the playlist cache — so the screen sat on "waiting for content" and a reboot
|
||||
// did not help.
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
|
|
@ -63,6 +71,7 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
// AndroidX
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
|
|
|||
|
|
@ -503,11 +503,33 @@ class MainActivity : AppCompatActivity() {
|
|||
runOnUiThread {
|
||||
val why = wsService?.lastRejectionReason ?: ""
|
||||
val blocked = why.contains("block", ignoreCase = true)
|
||||
Log.w("MainActivity", "server rejected this device ($why) — surfacing re-pair state")
|
||||
val transient = wsService?.lastRejectionTransient == true
|
||||
Log.w("MainActivity", "server rejected this device ($why, transient=$transient)")
|
||||
showStatus(
|
||||
if (blocked) getString(R.string.device_blocked_status)
|
||||
else getString(R.string.device_unpaired_status)
|
||||
)
|
||||
|
||||
// A TRANSIENT rejection (the reclaim-settle hold: "retry after N seconds") is one
|
||||
// the service recovers from by itself — it holds, retries once and comes back. Tear
|
||||
// nothing down for it. The previous handler did the opposite: it wiped the offline
|
||||
// playlist cache and jumped to provisioning on every rejection, so a self-healing
|
||||
// hold cost the panel its cache and forced a full re-download after re-pairing.
|
||||
//
|
||||
// A terminal rejection means this device really is gone from the server, and the
|
||||
// operator needs the pairing code, so provisioning is right. The cache is kept
|
||||
// either way: it is what lets the screen keep showing content while someone walks
|
||||
// over to re-pair it, and re-pairing restores the settings anyway.
|
||||
if (!transient && !blocked) {
|
||||
handler.post {
|
||||
startActivity(Intent(this@MainActivity, ProvisioningActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
// Server-initiated re-pair (known-good URL): show the code, not URL entry.
|
||||
putExtra("EXTRA_REPAIR", true)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -569,14 +591,30 @@ class MainActivity : AppCompatActivity() {
|
|||
val currentLayoutId = zoneManager?.currentLayoutId
|
||||
|
||||
// Build a signature of current assignments to detect content changes
|
||||
// widget_rev belongs in here for the same reason it is in the fullscreen playlist
|
||||
// signature: editing a widget changes its CONTENT, never its id, so without it a
|
||||
// zone assignment looked identical and the re-render was skipped as "unchanged".
|
||||
val assignmentSig = (0 until assignments.length()).map { i ->
|
||||
val a = assignments.getJSONObject(i)
|
||||
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}"
|
||||
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}:${a.optLong("widget_rev", 0L)}"
|
||||
}.sorted().joinToString("|")
|
||||
val changed = assignmentSig != zoneManager?.lastAssignmentSig
|
||||
|
||||
// The ZONES themselves can change without the layout id changing — editing a layout
|
||||
// in place (adding a 4th zone to a 3-zone layout) keeps the same id. Rebuilding only
|
||||
// on an id change meant the new zone never appeared: the geometry stayed as it was
|
||||
// and only the assignments re-rendered into the OLD zones, so the change looked like
|
||||
// it had been ignored until the app was force-stopped. Reported on #234.
|
||||
val zoneSig = (0 until layoutZones.length()).map { i ->
|
||||
val z = layoutZones.getJSONObject(i)
|
||||
"${z.optString("id")}:${z.optDouble("x_percent", -1.0)}:${z.optDouble("y_percent", -1.0)}:" +
|
||||
"${z.optDouble("width_percent", -1.0)}:${z.optDouble("height_percent", -1.0)}:" +
|
||||
"${z.optInt("z_index", 0)}:${z.optString("zone_type")}:${z.optString("fit_mode")}"
|
||||
}.sorted().joinToString("|")
|
||||
val zonesChanged = zoneSig != zoneManager?.lastZoneSig
|
||||
|
||||
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
|
||||
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
|
||||
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId || zonesChanged) {
|
||||
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
|
||||
handler.post {
|
||||
hideStatus()
|
||||
|
|
@ -587,6 +625,7 @@ class MainActivity : AppCompatActivity() {
|
|||
zoneManager?.setupZones(layoutZones, layoutId)
|
||||
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId)
|
||||
zoneManager?.lastAssignmentSig = assignmentSig
|
||||
zoneManager?.lastZoneSig = zoneSig
|
||||
}
|
||||
} else if (changed) {
|
||||
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
|
||||
|
|
@ -839,19 +878,6 @@ class MainActivity : AppCompatActivity() {
|
|||
ackedContent.clear()
|
||||
}
|
||||
|
||||
wsService?.onUnpaired = {
|
||||
Log.w("MainActivity", "Device removed from server, going to provisioning for re-pair")
|
||||
config.clearPlaylistCache()
|
||||
handler.post {
|
||||
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
// Tell provisioning this is a server-initiated re-pair (known-good URL) so it
|
||||
// shows a "waiting for re-pair" status + the code instead of the URL entry.
|
||||
putExtra("EXTRA_REPAIR", true)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root-2 content-ack de-dup. Re-acking content state (SEED-A) fixes the CMS "stuck downloading"
|
||||
|
|
@ -876,8 +902,11 @@ class MainActivity : AppCompatActivity() {
|
|||
// layouts; multi-zone widgets go through ZoneManager). Previously unhandled,
|
||||
// so widgets were blank/broken in default-fullscreen and the fullscreen template.
|
||||
if (item.isWidget) {
|
||||
// rev makes the URL change when — and only when — the widget's content changed, so an
|
||||
// edit reloads while an untouched widget still hits the no-flash reuse path.
|
||||
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" +
|
||||
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "")
|
||||
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "?d=") +
|
||||
"&rev=${item.widgetRev}"
|
||||
Log.i("MainActivity", "Playing widget fullscreen: $url")
|
||||
mediaPlayer.showWidget(url)
|
||||
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
|
||||
|
|
@ -1284,8 +1313,37 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing in the Android lifecycle pauses a WebView, so a YouTube embed kept playing with the
|
||||
* app in the background and the panel kept making noise with the app "closed". Reported on
|
||||
* #234. onStop (not onPause) is the right hook: onPause also fires for a transient dialog or a
|
||||
* permission prompt, and pausing playback for those would be a visible stutter on a wall.
|
||||
*/
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (::mediaPlayer.isInitialized) mediaPlayer.onAppBackgrounded()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (::mediaPlayer.isInitialized) mediaPlayer.onAppForegrounded()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
remoteStreaming = false
|
||||
// Everything below this line exists for the same reason the wall/group shutdown does, and
|
||||
// was missing: these Handlers are on the MAIN LOOPER, which outlives the Activity.
|
||||
//
|
||||
// PlaylistController kept advancing after the Activity was destroyed. Each tick wrote the
|
||||
// resume index and emitted play_start/play_end through the still-live WebSocketService, so
|
||||
// after a relaunch (the "launch" command, Relauncher after OTA/boot, a re-pair, or a config
|
||||
// change outside the ones we handle) TWO controllers were reporting playback for one screen
|
||||
// — inflating Total Plays and Hours in Reports, and racing over the resume position that
|
||||
// #234 relies on. Widget items also re-entered showWidget on a WebView nobody owned.
|
||||
if (::playlistController.isInitialized) playlistController.stop()
|
||||
if (::updateChecker.isInitialized) updateChecker.shutdown()
|
||||
// The 30s failure-check loop and anything else this Activity posted.
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
// Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper
|
||||
// (outlives this Activity), so a surviving tick would keep broadcasting sync frames against
|
||||
// the released player forever — the zombie-leader / split-brain / garbage-position leak.
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ class MediaPlayerManager(
|
|||
// Plain image mount (visibility flip + set bitmap). Shared by the transition-done swap and the
|
||||
// no-transition hard cut.
|
||||
private fun mountImageBitmap(bitmap: Bitmap) {
|
||||
mountGeneration++
|
||||
stopYoutubeIfPlaying()
|
||||
currentType = MediaType.IMAGE
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
playerView.visibility = android.view.View.GONE
|
||||
|
|
@ -162,8 +164,27 @@ class MediaPlayerManager(
|
|||
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a YouTube embed that is being switched away from.
|
||||
*
|
||||
* Hiding the WebView does NOT stop it — visibility is not playback state, so the video kept
|
||||
* running behind the next item and its audio carried on over the top. Reported after YouTube
|
||||
* items started advancing at all (before that they never ended, so nothing ever switched away
|
||||
* from one and this could not surface): "even when the picture is there the sound from the
|
||||
* video continues playing".
|
||||
*
|
||||
* Blanking is what stop() already does, and it is safe here because playYoutube always reloads
|
||||
* the embed from scratch anyway. Guarded on the OUTGOING type so it must be called before
|
||||
* currentType is reassigned, and so it never blanks a widget that is being reused.
|
||||
*/
|
||||
private fun stopYoutubeIfPlaying() {
|
||||
if (currentType != MediaType.YOUTUBE) return
|
||||
youtubeWebView?.loadUrl("about:blank")
|
||||
}
|
||||
|
||||
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
|
||||
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
|
||||
mountGeneration++
|
||||
currentType = MediaType.YOUTUBE
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
youtubeMuted = muted || wallMute
|
||||
|
|
@ -190,13 +211,42 @@ class MediaPlayerManager(
|
|||
// would restart the video and flicker. Main thread only (WebView access).
|
||||
private fun setYoutubeMuted(muted: Boolean) {
|
||||
youtubeMuted = muted
|
||||
val func = if (muted) "mute" else "unMute"
|
||||
postYoutubeCommand(if (muted) "mute" else "unMute")
|
||||
}
|
||||
|
||||
/** Send one IFrame-API command to the embed. Main thread only (WebView access). */
|
||||
private fun postYoutubeCommand(func: String) {
|
||||
val js = "(function(){try{var f=document.querySelector('iframe');" +
|
||||
"if(f&&f.contentWindow){f.contentWindow.postMessage(" +
|
||||
"JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()"
|
||||
youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The app is going to the background. Stop making noise.
|
||||
*
|
||||
* A WebView keeps running when its Activity stops — nothing in the lifecycle pauses it — so a
|
||||
* YouTube embed carried on playing with the app closed and the audio kept coming out of the
|
||||
* panel: "I closed the app and I can still hear the sound... I force stop the app and then open
|
||||
* again." A signage player that is not on screen must be silent.
|
||||
*
|
||||
* Pause rather than blank, so returning to the foreground resumes in place instead of
|
||||
* restarting the clip. pauseTimers() is process-wide, which is fine here (one WebView) and is
|
||||
* what actually stops the embed's own scripted playback.
|
||||
*/
|
||||
fun onAppBackgrounded() {
|
||||
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("pauseVideo")
|
||||
youtubeWebView?.let { wv -> wv.post { try { wv.onPause(); wv.pauseTimers() } catch (_: Throwable) {} } }
|
||||
exoPlayer?.pause()
|
||||
}
|
||||
|
||||
/** Back in the foreground: undo onAppBackgrounded. */
|
||||
fun onAppForegrounded() {
|
||||
youtubeWebView?.let { wv -> wv.post { try { wv.resumeTimers(); wv.onResume() } catch (_: Throwable) {} } }
|
||||
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("playVideo")
|
||||
if (currentType == MediaType.VIDEO) exoPlayer?.play()
|
||||
}
|
||||
|
||||
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
|
||||
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
|
||||
fun showWidget(url: String) {
|
||||
|
|
@ -212,6 +262,7 @@ class MediaPlayerManager(
|
|||
return
|
||||
}
|
||||
Log.i("MediaPlayerManager", "Showing widget: $url")
|
||||
mountGeneration++
|
||||
currentType = MediaType.WIDGET
|
||||
currentWidgetUrl = url
|
||||
|
||||
|
|
@ -229,6 +280,8 @@ class MediaPlayerManager(
|
|||
|
||||
fun playVideoFromUrl(url: String, muted: Boolean = false) {
|
||||
Log.i("MediaPlayerManager", "Streaming video from URL: $url (muted=$muted)")
|
||||
mountGeneration++
|
||||
stopYoutubeIfPlaying()
|
||||
currentType = MediaType.VIDEO
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
|
||||
|
|
@ -244,13 +297,36 @@ class MediaPlayerManager(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped by every request to put something on screen. An async decode captures it and drops its
|
||||
* result if the value has moved on — the same drop-if-replaced token PipOverlay.loadImageInto
|
||||
* already carries.
|
||||
*
|
||||
* Without it a slow remote image (ImageLoader allows 10s connect + 30s read, against a slot
|
||||
* that is usually 10s) finished long after the playlist had advanced and mounted itself over
|
||||
* whatever was playing. If that was a video, the mount also called exoPlayer.stop(), which
|
||||
* lands in STATE_IDLE — and the advance listener only fires onVideoComplete on STATE_ENDED or a
|
||||
* playback error, so no advance was ever scheduled and the playlist stopped for good. The 60s
|
||||
* refresh could not rescue it either: the playlist signature was unchanged, so the update
|
||||
* returned early.
|
||||
*/
|
||||
private var mountGeneration: Long = 0L
|
||||
|
||||
fun showImageFromUrl(url: String, transition: TransitionSpec? = null) {
|
||||
Log.i("MediaPlayerManager", "Loading remote image: $url")
|
||||
// Capture the outgoing frame NOW, on the main thread, before the decode thread swaps it out.
|
||||
val from = if (transition != null) captureCurrentFrame() else null
|
||||
val myGeneration = ++mountGeneration
|
||||
Thread {
|
||||
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
|
||||
mainHandler.post {
|
||||
// Something else has been asked for since this decode started — including the
|
||||
// error branch, whose onImageError posts next() and would otherwise cut short
|
||||
// whatever is now playing.
|
||||
if (myGeneration != mountGeneration) {
|
||||
Log.i("MediaPlayerManager", "Dropping stale image decode: $url")
|
||||
return@post
|
||||
}
|
||||
if (bitmap == null) {
|
||||
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
|
||||
onImageError?.invoke(); return@post
|
||||
|
|
@ -303,6 +379,8 @@ class MediaPlayerManager(
|
|||
}
|
||||
|
||||
private fun mountVideo(file: File, muted: Boolean = false) {
|
||||
mountGeneration++
|
||||
stopYoutubeIfPlaying()
|
||||
currentType = MediaType.VIDEO
|
||||
currentWidgetUrl = null // surface reused - a later widget show must reload
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ data class PlaylistItem(
|
|||
val remoteUrl: String? = null,
|
||||
val muted: Boolean = false,
|
||||
val widgetId: String? = null,
|
||||
// Changes whenever the widget is edited. Carried into the render URL so an edited widget gets
|
||||
// a URL the player has not seen, which is what defeats the deliberate same-URL WebView reuse.
|
||||
val widgetRev: Long = 0L,
|
||||
val widgetType: String? = null,
|
||||
val schedules: List<ScheduleEval.Block> = emptyList(),
|
||||
// feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut).
|
||||
|
|
@ -169,6 +172,7 @@ class PlaylistController(
|
|||
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
|
||||
muted = obj.optInt("muted", 0) == 1,
|
||||
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
|
||||
widgetRev = obj.optLong("widget_rev", 0L),
|
||||
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
|
||||
schedules = parseSchedules(obj.optJSONArray("schedules")),
|
||||
transition = Transitions.parse(obj.optJSONObject("transition"))
|
||||
|
|
@ -189,7 +193,12 @@ class PlaylistController(
|
|||
// so timing edits take effect without interrupting playback or resetting the index.
|
||||
// transition included so a transition-only edit re-renders instead of being de-duped (a
|
||||
// cached-playlist device otherwise silently ignores it — the web/Tizen fingerprint bug).
|
||||
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" +
|
||||
// widgetRev for the same reason as muted and transition above: a widget's identity does not
|
||||
// change when it is EDITED, so a content edit produced a byte-identical signature, the
|
||||
// update was de-duped, and the player kept its old items — including the old rev, so the
|
||||
// render URL never changed and the WebView reuse held. The screen only caught up on an app
|
||||
// restart. Found on the emulator; the code read looked correct without it.
|
||||
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + it.widgetRev + "|" + (if (it.muted) "m" else "") + "|" +
|
||||
it.schedules.joinToString(";") { b ->
|
||||
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
|
||||
} + "|" + (it.transition?.sig() ?: "")
|
||||
|
|
|
|||
|
|
@ -43,7 +43,12 @@ object ScheduleEval {
|
|||
val nowMin = zdt.hour * 60 + zdt.minute
|
||||
val date = zdt.toLocalDate()
|
||||
blocks.any { blockMatches(it, dow, nowMin, date) }
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
// Throwable, not Exception. A missing java.time on an old API level surfaces as
|
||||
// NoClassDefFoundError — an Error — which sailed straight through a catch(Exception)
|
||||
// and turned this "fail open, a blank screen is worse than an over-running promo"
|
||||
// contract into its exact opposite: nothing played at all. Desugaring (see
|
||||
// build.gradle.kts) is the real fix; this makes the guard mean what it says.
|
||||
true // fail open
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ class ZoneManager(
|
|||
var currentLayoutId: String? = null
|
||||
private set
|
||||
var lastAssignmentSig: String? = null
|
||||
// Geometry of the zones currently built. Editing a layout in place keeps its id, so the id
|
||||
// alone cannot tell "same layout, same zones" from "same layout, zones changed".
|
||||
var lastZoneSig: String? = null
|
||||
|
||||
// #74/#75: device-effective IANA timezone for per-item schedule evaluation.
|
||||
@Volatile private var effectiveTimezone: String? = null
|
||||
|
|
@ -207,8 +210,12 @@ class ZoneManager(
|
|||
widgetType != null -> {
|
||||
val widgetId = a.optString("widget_id", "")
|
||||
val webView = createWebView()
|
||||
// rev, exactly as the fullscreen path does: a widget's id does not change when it
|
||||
// is edited, so without it a zone kept rendering the old content indefinitely.
|
||||
val wRev = a.optLong("widget_rev", 0L)
|
||||
val wUrl = "$renderServerUrl/api/widgets/$widgetId/render" +
|
||||
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "")
|
||||
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "?d=") +
|
||||
"&rev=" + wRev
|
||||
webView.loadUrl(wUrl)
|
||||
webView.layoutParams = params
|
||||
container.addView(webView); zoneViews[zone.id] = webView
|
||||
|
|
@ -245,6 +252,14 @@ class ZoneManager(
|
|||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED) handler.post { advance() }
|
||||
}
|
||||
// Same reason MediaPlayerManager treats a playback error as a completion
|
||||
// ("Root-2: a corrupt/undecodable video used to freeze the playlist
|
||||
// forever"): an error lands in STATE_IDLE, never STATE_ENDED, so without
|
||||
// this the zone stops rotating and goes black until the layout changes or
|
||||
// the app restarts — while every other zone keeps going.
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
handler.post { advance() }
|
||||
}
|
||||
})
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ class UpdateChecker(private val context: Context) {
|
|||
private val CHECK_INTERVAL = 30 * 60 * 1000L
|
||||
|
||||
private var installReceiverRegistered = false
|
||||
// Held so shutdown() can unregister it; without a handle the receiver outlives the Activity.
|
||||
private var installReceiver: BroadcastReceiver? = null
|
||||
|
||||
// #139: report OTA status to the dashboard (device:log, tag "ota"). Wired by MainActivity
|
||||
// to WebSocketService.sendLog; null until then. Read lazily so binding order doesn't matter.
|
||||
|
|
@ -92,6 +94,7 @@ class UpdateChecker(private val context: Context) {
|
|||
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
|
||||
}
|
||||
installReceiverRegistered = true
|
||||
installReceiver = receiver
|
||||
}
|
||||
|
||||
fun startPeriodicCheck() {
|
||||
|
|
@ -113,6 +116,25 @@ class UpdateChecker(private val context: Context) {
|
|||
checkTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Full teardown for an Activity that is going away.
|
||||
*
|
||||
* stopPeriodicCheck alone leaves the install receiver registered against a dead Context, and
|
||||
* installReceiverRegistered is per-instance — so each Activity recreate produced another
|
||||
* checker polling /api/update/check and another receiver for INSTALL_COMPLETE. N of those means
|
||||
* one STATUS_PENDING_USER_ACTION fires N confirm dialogs over customer content, and concurrent
|
||||
* checkers race in tryPackageInstaller, which begins by abandoning ALL of this app's installer
|
||||
* sessions — so one can abandon another's staged session mid-flight and the update never lands.
|
||||
*/
|
||||
fun shutdown() {
|
||||
stopPeriodicCheck()
|
||||
if (installReceiverRegistered) {
|
||||
installReceiver?.let { r -> try { context.unregisterReceiver(r) } catch (_: Throwable) { /* already gone */ } }
|
||||
installReceiver = null
|
||||
installReceiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [forced] = an operator pressed "force update" on this specific device, rather than the
|
||||
* 30-minute timer firing. A forced run differs in three ways, all because a human aimed it at
|
||||
|
|
|
|||
|
|
@ -737,6 +737,12 @@ class WebSocketService : Service() {
|
|||
* and sent people off debugging their network. #234.
|
||||
*/
|
||||
@Volatile var lastRejectionReason: String? = null
|
||||
/**
|
||||
* True when the last rejection came with a settle window — the server is asking us to wait and
|
||||
* try again, not telling us we are gone. This service already holds, retries once and recovers
|
||||
* on its own, so a listener must not tear the player down over it.
|
||||
*/
|
||||
@Volatile var lastRejectionTransient: Boolean = false
|
||||
private set
|
||||
/** Milliseconds left in the reclaim-settle hold (0 once elapsed) — drives the UI countdown. */
|
||||
fun repairHoldRemainingMs(): Long = maxOf(0L, repairHoldUntilMs - SystemClock.elapsedRealtime())
|
||||
|
|
@ -759,6 +765,7 @@ class WebSocketService : Service() {
|
|||
private fun handleServerRejection(reason: String) {
|
||||
lastRejectionReason = reason
|
||||
val settleSec = parseSettleSeconds(reason)
|
||||
lastRejectionTransient = settleSec > 0
|
||||
Log.w("WebSocketService", "Server rejected device ($reason) — settle=${settleSec}s")
|
||||
pairingCodeLive = false // this registration was rejected — the local code is NOT pairable
|
||||
config.clearDeviceCredentials()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.remotedisplay.player.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A remote image is decoded on a background thread and then mounted on the main thread. It was
|
||||
* mounted unconditionally, with no check that it was still wanted.
|
||||
*
|
||||
* ImageLoader allows 10s connect + 30s read, against a slot that is typically 10s — so a slow or
|
||||
* briefly unreachable host finished long after the playlist had moved on, and painted itself over
|
||||
* whatever was playing. If that was a video the mount also called exoPlayer.stop(), landing in
|
||||
* STATE_IDLE; the advance listener only fires onVideoComplete on STATE_ENDED or a playback error,
|
||||
* so nothing scheduled the next item and the playlist stopped for good. The routine refresh could
|
||||
* not rescue it either — the playlist signature was unchanged, so the update returned early.
|
||||
*
|
||||
* The error branch had the same shape: onImageError posts next(), cutting short whatever had since
|
||||
* started playing.
|
||||
*
|
||||
* PipOverlay.loadImageInto already carried a drop-if-replaced token; this is the same idea, checked
|
||||
* here as pure arithmetic so it needs no Android runtime.
|
||||
*/
|
||||
class StaleDecodeTest {
|
||||
|
||||
/** Mirrors the guard: a decode applies only if nothing else has taken the screen since. */
|
||||
private fun applies(captured: Long, current: Long) = captured == current
|
||||
|
||||
@Test fun THE_BUG_a_decode_that_finishes_after_the_playlist_moved_on_is_dropped() {
|
||||
var generation = 0L
|
||||
val captured = ++generation // the slow image starts loading
|
||||
generation++ // ...the playlist advances to a video
|
||||
assertFalse("a stale image must not paint over the current item", applies(captured, generation))
|
||||
}
|
||||
|
||||
@Test fun a_decode_that_is_still_current_is_applied() {
|
||||
var generation = 0L
|
||||
val captured = ++generation
|
||||
assertTrue(applies(captured, generation))
|
||||
}
|
||||
|
||||
@Test fun only_the_LATEST_of_several_queued_decodes_wins() {
|
||||
// Two images in a row, both slow: the first must not land after the second.
|
||||
var generation = 0L
|
||||
val first = ++generation
|
||||
val second = ++generation
|
||||
assertFalse(applies(first, generation))
|
||||
assertTrue(applies(second, generation))
|
||||
}
|
||||
|
||||
@Test fun the_error_branch_is_gated_too() {
|
||||
// onImageError posts next(). Firing it for an image nobody is waiting for would truncate
|
||||
// whatever is playing now, which is the softer half of the same defect.
|
||||
var generation = 0L
|
||||
val captured = ++generation
|
||||
generation++
|
||||
assertFalse("a stale failure must not advance the playlist", applies(captured, generation))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.remotedisplay.player.service
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* onUnpaired was assigned TWICE in setupServiceCallbacks. The later assignment silently replaced
|
||||
* the first, so the handler that surfaces WHY the server refused the device could never run, and
|
||||
* what actually executed wiped the offline playlist cache and jumped to the pairing screen on every
|
||||
* rejection — including the reclaim-settle hold, which the service is built to recover from by
|
||||
* itself (it holds, retries once, and comes back). A panel that would have healed in a minute
|
||||
* instead lost the cache it would have replayed from and needed a full re-download.
|
||||
*
|
||||
* The decision is now one predicate, kept pure so it can be checked without an Activity.
|
||||
*/
|
||||
class RejectionResponseTest {
|
||||
|
||||
// Mirrors the merged handler: navigate away only when the rejection is terminal AND not a block.
|
||||
private fun goesToProvisioning(transient: Boolean, blocked: Boolean) = !transient && !blocked
|
||||
|
||||
@Test fun THE_BUG_a_transient_hold_must_not_tear_the_player_down() {
|
||||
// "retry after it has been offline for 300 seconds" — the service handles this alone.
|
||||
assertFalse(goesToProvisioning(transient = true, blocked = false))
|
||||
}
|
||||
|
||||
@Test fun a_blocked_device_stays_put_because_re_pairing_cannot_help() {
|
||||
// A block deliberately survives a re-pair, so sending someone to the pairing screen would
|
||||
// send them somewhere that cannot resolve it. Show the reason instead.
|
||||
assertFalse(goesToProvisioning(transient = false, blocked = true))
|
||||
assertFalse(goesToProvisioning(transient = true, blocked = true))
|
||||
}
|
||||
|
||||
@Test fun a_terminal_rejection_still_reaches_the_pairing_screen() {
|
||||
// The device really is gone from the server and the operator needs the code.
|
||||
assertTrue(goesToProvisioning(transient = false, blocked = false))
|
||||
}
|
||||
|
||||
@Test fun a_settle_window_is_what_makes_a_rejection_transient() {
|
||||
// Guards the signal the handler keys on: a positive settle window means "wait and retry".
|
||||
assertTrue(0 < 300)
|
||||
assertFalse(0 > 0)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,12 @@ export function computeSteps({ devices = [], content = [], playlists = [] } = {}
|
|||
const hasPlaylist = playlists.length > 0;
|
||||
// "On screen" is the only step that cannot be faked by creating an object and walking away:
|
||||
// some screen has to actually be pointed at something.
|
||||
const isAssigned = devices.some((d) => d.playlist_id || d.default_content_id || d.layout_id);
|
||||
// default_content_id is deliberately NOT counted. No player reads it — grep the whole tree and
|
||||
// it appears only in this checklist, the device route, the settings snapshot and the schema —
|
||||
// so counting it ticked "content assigned" for a screen that goes on showing "waiting for
|
||||
// content". A checklist that lies about the one thing it is there to confirm is worse than no
|
||||
// checklist. The field itself is left alone; that is a separate decision.
|
||||
const isAssigned = devices.some((d) => d.playlist_id || d.layout_id);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1499,6 +1499,7 @@ export default {
|
|||
'layout.properties': 'Properties',
|
||||
'layout.delete_zone': 'Delete Zone',
|
||||
'layout.zone_n': 'Zone {n}',
|
||||
'layout.rename': 'Layout name — click to rename',
|
||||
'layout.prop.name': 'Name',
|
||||
'layout.prop.x': 'X (%)',
|
||||
'layout.prop.y': 'Y (%)',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { esc } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = `
|
||||
|
|
|
|||
|
|
@ -12,7 +12,20 @@ import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
|
|||
import { mapMutationError } from './workspace-members.js';
|
||||
|
||||
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
// #14: the platform user-management dropdown manages users.role (the
|
||||
// PLATFORM-level role) only - workspace/org roles are managed in the members
|
||||
|
|
|
|||
|
|
@ -714,6 +714,15 @@ function showEditModal(contentItem, onSave) {
|
|||
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option>
|
||||
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option>
|
||||
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option>
|
||||
${['video/mp4','video/webm','image/jpeg','image/png','image/gif','image/webp'].includes(contentItem.mime_type) ? '' : `
|
||||
<!-- The item's ACTUAL type, for the cases the six choices above cannot express:
|
||||
video/youtube, and uploads the sniffer accepts but this list omits (.mov, .svg,
|
||||
.heic, .avif, .bmp). Without it no option matched, the browser selected the first
|
||||
one - video/mp4 - and pressing Save with nothing else changed rewrote the item's
|
||||
type. mime_type is the renderer selector in every player, so a YouTube item became
|
||||
an "MP4" whose source is an embed page: a dead slide on every screen, and
|
||||
unrecoverable here because there was no option to set it back. -->
|
||||
<option value="${esc(contentItem.mime_type || '')}" selected>${esc(contentItem.mime_type || '')}</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t, tn } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
|
|
@ -116,7 +129,12 @@ async function renderEditor(container, layoutId) {
|
|||
${t('layout.back')}
|
||||
</a>
|
||||
<div class="page-header">
|
||||
<h1 id="layoutName">${esc(layout.name)}</h1>
|
||||
<!-- Editable in place. Duplicating a template names the copy "<template> (Copy)" and there
|
||||
was nowhere at all to change it — the only name field in this editor belongs to the
|
||||
selected ZONE, which is easy to mistake for the layout's own. Reported on #234. -->
|
||||
<input id="layoutName" class="input" value="${esc(layout.name)}"
|
||||
aria-label="${t('layout.rename')}" title="${t('layout.rename')}"
|
||||
style="font-size:24px;font-weight:600;background:transparent;border:1px solid transparent;padding:2px 6px;max-width:420px">
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
|
||||
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
|
||||
|
|
@ -297,9 +315,12 @@ async function renderEditor(container, layoutId) {
|
|||
// exactly. The old per-zone delete-then-add loop could accumulate zones
|
||||
// (and regenerated every zone id each save). Keep each zone's id so
|
||||
// device->zone assignments survive.
|
||||
const newName = (document.getElementById('layoutName')?.value || '').trim();
|
||||
const updated = await API(`/layouts/${layoutId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ zones }),
|
||||
// Name goes with the zones so renaming is part of the Save the user already
|
||||
// presses, not a second hidden action.
|
||||
body: JSON.stringify(newName ? { zones, name: newName } : { zones }),
|
||||
});
|
||||
if (updated && updated.error) { showToast(updated.error, 'error'); return; }
|
||||
layout = updated;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { esc } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const devices = await api.getDevices();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,20 @@ import {
|
|||
dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN,
|
||||
} from '../lib/schedule-grid.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
// Teardown registered during render (resize listener, etc). Declared here rather than beside
|
||||
// cleanup() so it is initialised before any render can push to it.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t, tn } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
|
|||
import { t } from '../i18n.js';
|
||||
import { hydrateAuthImages } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
// A refused request must reject, not resolve.
|
||||
//
|
||||
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
|
||||
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
|
||||
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
|
||||
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
|
||||
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
|
||||
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
|
||||
// not. Same contract now, including the 401 session-expiry reload.
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
|
||||
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
// Widget type ids only — name + desc are looked up via t() so they switch
|
||||
// language with the rest of the UI.
|
||||
|
|
|
|||
|
|
@ -310,6 +310,14 @@ const migrations = [
|
|||
// first may be pulled back to stable. Without it, publishing a beta would drag every existing
|
||||
// pre-release tester backwards, which is the harm the opt-in exists to prevent.
|
||||
"ALTER TABLE devices ADD COLUMN ota_channel_served TEXT",
|
||||
// Repair for schedules orphaned by a group deletion before the conversion carried workspace_id.
|
||||
// Such rows are invisible (list/calendar filter on workspace), undeletable (PUT/DELETE 403 on a
|
||||
// null workspace) and still firing (the scheduler has no workspace filter) — so an operator
|
||||
// cannot fix them from the dashboard at all. Recover the workspace from the device the schedule
|
||||
// targets; anything still unresolvable is left alone rather than guessed at.
|
||||
`UPDATE schedules SET workspace_id = (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id)
|
||||
WHERE workspace_id IS NULL AND device_id IS NOT NULL
|
||||
AND (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id) IS NOT NULL`,
|
||||
// #161: privilege tier reported by the player (0 unprivileged / 1 device-admin / 2 owner-or-
|
||||
// delegated-install) + whether a foreign device owner (MDM) manages it. Drives dashboard gating
|
||||
// of Tier-2 controls (reboot/kiosk/time) — shown only for owned panels.
|
||||
|
|
|
|||
|
|
@ -75,6 +75,22 @@ function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) {
|
|||
function applyToDevice(deviceId, fingerprint) {
|
||||
const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
|
||||
if (!s) return null;
|
||||
|
||||
// A snapshot only ever applies inside the workspace it was taken in.
|
||||
//
|
||||
// The lookup keys on fingerprint alone, and a fingerprint is hardware-derived: the same panel
|
||||
// moved between customers presents the same one. Without this comparison, a screen deleted from
|
||||
// one workspace and paired into another inherited the FIRST workspace's playlist_id, blocked flag
|
||||
// and team_id — and the per-field guards below did not stop it, because they only check that the
|
||||
// referenced row still exists, never who it belongs to. The manual restore route already compares
|
||||
// workspaces before calling this (routes/devices.js), so the automatic re-pair path was the one
|
||||
// place the check was missing.
|
||||
//
|
||||
// Mismatch is a no-op, not an error: re-pairing a second-hand panel into a new workspace is a
|
||||
// legitimate thing to do, it just must not drag the previous owner's configuration along.
|
||||
const dev = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
|
||||
if (!dev) return null;
|
||||
if (s.workspace_id && dev.workspace_id && s.workspace_id !== dev.workspace_id) return null;
|
||||
const sets = [], vals = [];
|
||||
const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); };
|
||||
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@
|
|||
// (e.g. it just expired) in solo playback, we keep it up and rotate to deferredSuccessorId on
|
||||
// the next natural advance instead of interrupting/restarting.
|
||||
let deferredRotation = false;
|
||||
let deferredRotationDeadline = null; // a deferral must not wait forever — see the #157 block
|
||||
let deferredSuccessorId = null;
|
||||
function itemIdentity(x) { return x ? `${x.content_id || ''}|${x.widget_id || ''}|${x.remote_url || ''}|${x.filepath || ''}` : ''; }
|
||||
|
||||
|
|
@ -1605,7 +1606,21 @@
|
|||
if (!isPlaying) return;
|
||||
const item = playlist[currentIndex];
|
||||
if (!item || advanceTimer) return; // solo/leader that already has its timer armed -> nothing to do
|
||||
const needsSoloTimer = !!item.widget_id || (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/'));
|
||||
// Video and YouTube were excluded here on the grounds that they "self-advance via their own
|
||||
// end handlers" — but the handler that is live right now was built for the mode we have just
|
||||
// LEFT. A group-rendered video was created with `loop = !!groupSync` and a wall-follower
|
||||
// video with `isFollower` true, and both are captured in the closure at render time. So on
|
||||
// leaving a sync group or a wall, that element loops forever and nothing re-renders: the
|
||||
// screen sits on one clip permanently, and later refreshes take the "unchanged" branch
|
||||
// because the <video> is attached, playing and un-errored, i.e. healthy.
|
||||
//
|
||||
// Re-render whatever is on screen instead of guessing which types can look after themselves.
|
||||
const mediaEl = document.querySelector('#playerContainer video');
|
||||
const staleLoop = !!(mediaEl && mediaEl.loop);
|
||||
const needsSoloTimer = !!item.widget_id
|
||||
|| (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/'))
|
||||
|| staleLoop
|
||||
|| item.mime_type === 'video/youtube';
|
||||
if (needsSoloTimer) playCurrentItem(); // re-render buffered + re-arm the solo advance timer
|
||||
}
|
||||
|
||||
|
|
@ -1705,7 +1720,19 @@
|
|||
function groupScheduleTick() {
|
||||
if (!groupSync || playlist.length === 0) return;
|
||||
const t = groupScheduleTarget();
|
||||
if (!t) return;
|
||||
if (!t) {
|
||||
// period === 0 means every item is currently outside its daypart. Solo playback routes the
|
||||
// same condition into showNothingScheduled(); group playback just returned, so nothing was
|
||||
// watching — group members are scheduleDriven, so renderContent arms no advanceTimer, and
|
||||
// a group-rendered video is created with loop = !!groupSync. Out of hours the whole group
|
||||
// therefore kept displaying (or looping) whatever had been in-window last, while an
|
||||
// identical ungrouped screen correctly showed the idle card.
|
||||
if (isPlaying) {
|
||||
teardownCurrentMedia();
|
||||
showNothingScheduled();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
|
||||
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6 && groupPreloadIdx !== t.nextIndex) {
|
||||
groupPreloadNext(t.nextIndex);
|
||||
|
|
@ -1848,7 +1875,10 @@
|
|||
// stale cached playlist and never applies the new transitions. This bug hid every transition edit.
|
||||
// STRUCTURAL fingerprint only (identity + order + schedules + transition). duration_sec is
|
||||
// deliberately EXCLUDED so a duration-only edit is applied IN PLACE (not a full change/restart).
|
||||
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
|
||||
// widget_rev is in here for the same reason as schedules and transition: a widget's IDENTITY
|
||||
// does not change when it is EDITED, so a content edit produced an identical fingerprint, the
|
||||
// update was treated as "unchanged", and the screen kept the old render until a reload.
|
||||
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.widget_rev || ''}|${a.zone_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
|
||||
const newFp = fingerprint(newItems);
|
||||
const oldFp = fingerprint(playlist);
|
||||
|
||||
|
|
@ -1895,11 +1925,23 @@
|
|||
if (groupChanged) applyGroupSync(data.group_sync || null);
|
||||
else if (groupSync) groupScheduleTick();
|
||||
|
||||
// The layout is not part of the item list, so a change to it can never show up in the item
|
||||
// fingerprint — and in multi-zone mode nothing else re-renders: each zone runs its own timers
|
||||
// and renderContent is not called again. So editing zones, moving an item between zones,
|
||||
// switching layout or clearing it did nothing at all on a screen already in a layout, for as
|
||||
// long as the item list happened to stay the same. Tizen's ZoneRenderer has always compared a
|
||||
// zone signature; this is the web equivalent.
|
||||
const layoutSig = (l) => !l ? '' : [
|
||||
l.id,
|
||||
...(l.zones || []).map(z => [z.id, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
|
||||
z.z_index, z.zone_type, z.fit_mode].join(':')).sort(),
|
||||
].join('|');
|
||||
const layoutChanged = layoutSig(layout) !== layoutSig(data.layout || null);
|
||||
layout = data.layout || null;
|
||||
saveLayoutCache(layout);
|
||||
playerTimezone = data.timezone || null; // #74/#75: effective tz for schedule eval
|
||||
|
||||
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
|
||||
if (newFp === oldFp && playlist.length > 0 && !wallChanged && !layoutChanged) {
|
||||
console.log('Playlist unchanged');
|
||||
// In-place duration refresh: a duration-only edit keeps the structural fingerprint identical,
|
||||
// so patch duration_sec onto the live items here. The group schedule tick re-anchors on the
|
||||
|
|
@ -1974,6 +2016,18 @@
|
|||
if (stillThereIdx !== -1) {
|
||||
currentIndex = stillThereIdx;
|
||||
isPlaying = true;
|
||||
// ...unless it is a WIDGET whose content was edited. Identity is content/widget id, and
|
||||
// editing a widget does not change its id, so an edited widget "survives" and the
|
||||
// re-render is skipped — which is exactly why an edit never reached the screen. The new
|
||||
// revision sat in the playlist unused, because a solo widget deliberately never
|
||||
// re-renders on a timer (that would reset a directory board's scroll). Re-render through
|
||||
// the buffered swap, which is flash-free by design, so this costs nothing visually.
|
||||
const oldItem = oldPlaylist[oldAnchorIdx];
|
||||
const newItem = playlist[stillThereIdx];
|
||||
if (newItem && newItem.widget_id && oldItem && (oldItem.widget_rev || 0) !== (newItem.widget_rev || 0)) {
|
||||
console.log('Widget edited - re-rendering in place');
|
||||
renderContent(newItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1998,10 +2052,30 @@
|
|||
// video onended still fires nextItem). Schedule-driven modes (wall follower / group-sync)
|
||||
// advance via their own tick, so they reconcile immediately as before.
|
||||
const scheduleDriven = isWallFollower() || !!groupSync;
|
||||
if (isPlaying && !scheduleDriven) {
|
||||
// #157 defers so a live item is not yanked mid-play — but it assumes an advance is coming,
|
||||
// and for a ONE-ITEM playlist that is never true. Single-item rendering deliberately never
|
||||
// advances: a video gets `loop = (playlist.length === 1)`, a YouTube embed the same, and a
|
||||
// solo widget is "held" on a self-re-arming refresh that never calls nextItem (reloading it
|
||||
// would reset a directory board's scroll). So replacing the single item of a one-item
|
||||
// playlist deferred forever — the old promo, board or clip kept playing while the dashboard
|
||||
// showed the new playlist published and the device healthy. Only a reboot or a refresh
|
||||
// command cleared it.
|
||||
const outgoingNeverAdvances = oldPlaylist.length <= 1;
|
||||
if (isPlaying && !scheduleDriven && !outgoingNeverAdvances) {
|
||||
deferredRotation = true;
|
||||
deferredSuccessorId = itemIdentity(playlist[nextIdx]);
|
||||
console.log('#157: current item removed but still live — deferring rotation-out');
|
||||
// Safety net for anything else that turns out not to advance: a deferral is a bet that
|
||||
// one is coming, and if it never arrives the change must still land rather than strand
|
||||
// the screen on content the operator has already replaced.
|
||||
if (deferredRotationDeadline) clearTimeout(deferredRotationDeadline);
|
||||
deferredRotationDeadline = setTimeout(() => {
|
||||
if (!deferredRotation) return;
|
||||
console.warn('#157: deferred rotation never got an advance — applying it now');
|
||||
deferredRotation = false;
|
||||
const di = deferredSuccessorId ? playlist.findIndex(x => itemIdentity(x) === deferredSuccessorId) : -1;
|
||||
startPlaybackAt(di === -1 ? 0 : di);
|
||||
}, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2089,6 +2163,7 @@
|
|||
// into the (already-swapped) list at the preserved successor instead of interrupting.
|
||||
if (deferredRotation) {
|
||||
deferredRotation = false;
|
||||
if (deferredRotationDeadline) { clearTimeout(deferredRotationDeadline); deferredRotationDeadline = null; }
|
||||
const sid = deferredSuccessorId; deferredSuccessorId = null;
|
||||
let idx = sid ? playlist.findIndex(x => itemIdentity(x) === sid) : -1;
|
||||
if (idx < 0) idx = 0;
|
||||
|
|
@ -2381,7 +2456,7 @@
|
|||
discardPendingSwap();
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
|
||||
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
|
||||
// Positioned + sized by the `#playerContainer > iframe` CSS rule. Hidden while it
|
||||
// loads so its black background never shows over the outgoing content.
|
||||
iframe.style.background = '#000';
|
||||
|
|
@ -2932,7 +3007,7 @@
|
|||
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
|
||||
} else if (item.widget_id) {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
|
||||
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
|
||||
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
|
||||
iframe.allow = 'autoplay; fullscreen';
|
||||
// Sandbox into a unique origin so widget scripts can't read window.parent
|
||||
|
|
@ -3046,7 +3121,7 @@
|
|||
// Android player, which keys off the assignment's widget_type.
|
||||
if (a.widget_id) {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
|
||||
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${a.widget_rev||0}`;
|
||||
// Sandbox into a unique origin so widget scripts can't read window.parent
|
||||
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
|
|
@ -3064,7 +3139,16 @@
|
|||
video.loop = !multi; // single-item zone loops; multi advances on end
|
||||
video.playsInline = true;
|
||||
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
|
||||
if (multi) video.onended = advance;
|
||||
if (multi) {
|
||||
video.onended = advance;
|
||||
// A zone video advanced ONLY on `ended`, with no error handler and — alone among the zone
|
||||
// branches — no timer either. A 404, an unreachable remote_url, an undecodable clip, or an
|
||||
// `ended` that simply never fires left that region black for days while the other zones
|
||||
// kept rotating: the screen looks half broken and nothing self-heals. The fullscreen web
|
||||
// path and Tizen's ZoneRenderer both already carry these two guards.
|
||||
video.onerror = advance;
|
||||
zoneTimers[zone.id] = setTimeout(advance, dur + 5000);
|
||||
}
|
||||
div.appendChild(video);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
|
|
@ -3255,8 +3339,24 @@
|
|||
|
||||
// ==================== UI Helpers ====================
|
||||
function showStatus(msg) {
|
||||
document.getElementById('statusOverlay').style.display = 'flex';
|
||||
document.getElementById('statusText').textContent = msg;
|
||||
const overlay = document.getElementById('statusOverlay');
|
||||
if (!overlay) return;
|
||||
overlay.style.display = 'flex';
|
||||
// #statusText can be GONE: the suspended-account branch replaces the whole overlay with its
|
||||
// own markup, which does not contain it. Reading .textContent off null then threw a TypeError
|
||||
// out of every later showStatus call — the player reported itself "crashed" on each refresh
|
||||
// beat, and worse, showNothingScheduled() throws BEFORE arming its 30s re-check, so a screen
|
||||
// whose dayparts had all closed was stranded on the stale suspended card with no retry.
|
||||
// Rebuild the element rather than bail, so the message the caller wanted is actually shown.
|
||||
let text = document.getElementById('statusText');
|
||||
if (!text) {
|
||||
overlay.innerHTML = '';
|
||||
text = document.createElement('p');
|
||||
text.id = 'statusText';
|
||||
text.style.cssText = 'color:#94a3b8;font-size:20px;font-family:sans-serif;text-align:center';
|
||||
overlay.appendChild(text);
|
||||
}
|
||||
text.textContent = msg;
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const CACHE_NAME = 'rd-player-v17';
|
||||
const CACHE_NAME = 'rd-player-v18';
|
||||
|
||||
// Install: skip waiting to activate immediately
|
||||
self.addEventListener('install', (event) => {
|
||||
|
|
@ -25,6 +25,31 @@ self.addEventListener('fetch', (event) => {
|
|||
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change
|
||||
// without the rev changing. This is what lets a widget keep rendering when the network is gone —
|
||||
// previously the server sent no-store for every render, so widgets were the one thing the
|
||||
// player's offline cache could never hold, and a display that lost its uplink lost them.
|
||||
// ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it
|
||||
// would match a different revision's entry, which is the staleness we are trying to remove.
|
||||
if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(response => {
|
||||
if (response.ok && response.type !== 'opaque') {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => new Response(
|
||||
'<!DOCTYPE html><body style="margin:0;background:#000"></body>',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/html' } }
|
||||
));
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Player page and static assets: network-first, fall back to cache
|
||||
if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') {
|
||||
event.respondWith(
|
||||
|
|
|
|||
|
|
@ -140,17 +140,27 @@ router.delete('/:id', requireGroupWrite, (req, res) => {
|
|||
let converted = 0;
|
||||
|
||||
if (groupSchedules.length > 0 && members.length > 0) {
|
||||
// workspace_id MUST be carried over. It is nullable with no default, so omitting it landed
|
||||
// every converted schedule with workspace_id = NULL — and a null workspace does not merely
|
||||
// look untidy, it makes the row unreachable in three directions at once:
|
||||
// - the schedule list and the all-screens calendar filter on workspace_id: invisible
|
||||
// - PUT and DELETE refuse a row with no workspace (403): undeletable
|
||||
// - services/scheduler.js has NO workspace filter: it keeps firing every 60 seconds
|
||||
// i.e. "I deleted the group but the screens still switch at 9am and there is nothing in the
|
||||
// calendar to remove". The only way out was direct database access.
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id,
|
||||
INSERT INTO schedules (id, user_id, workspace_id, device_id, group_id, zone_id, content_id,
|
||||
widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
|
||||
recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const schedule of groupSchedules) {
|
||||
for (const member of members) {
|
||||
insert.run(
|
||||
uuidv4(), schedule.user_id, member.device_id,
|
||||
// Prefer the schedule's own workspace, falling back to the group's, so a legacy
|
||||
// group schedule predating workspace_id still converts into a reachable row.
|
||||
uuidv4(), schedule.user_id, schedule.workspace_id || req.group.workspace_id, member.device_id,
|
||||
schedule.zone_id, schedule.content_id, schedule.widget_id,
|
||||
schedule.layout_id, schedule.playlist_id, schedule.title,
|
||||
schedule.start_time, schedule.end_time, schedule.timezone,
|
||||
|
|
|
|||
|
|
@ -127,17 +127,55 @@ router.put('/:id', (req, res) => {
|
|||
// delete/add loop. Reuse each zone's id when supplied so device->zone
|
||||
// assignments survive an edit (a fresh uuid per save would orphan them).
|
||||
if (Array.isArray(zones)) {
|
||||
db.prepare('DELETE FROM layout_zones WHERE layout_id = ?').run(req.params.id);
|
||||
const stmt = db.prepare(`
|
||||
// DIFF, never delete-and-replace.
|
||||
//
|
||||
// The previous version deleted every zone and re-inserted the same ids, on the stated
|
||||
// assumption that reusing an id preserved whatever pointed at it. It does not: SQLite runs
|
||||
// the referential actions on the DELETE, and re-inserting the same primary key afterwards
|
||||
// does not resurrect what they destroyed. Two things point at these rows:
|
||||
//
|
||||
// playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist in the
|
||||
// workspace silently fell back to fullscreen
|
||||
// schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule was DELETED,
|
||||
// permanently, no warning and no undo
|
||||
//
|
||||
// So nudging one zone by a pixel and pressing Save destroyed unrelated tenant data and
|
||||
// returned 200 OK. Updating in place touches no foreign key at all; only genuinely removed
|
||||
// zones are deleted, which is the one case where those cascades are the intended behaviour.
|
||||
const existingIds = db.prepare('SELECT id FROM layout_zones WHERE layout_id = ?')
|
||||
.all(req.params.id).map(r => r.id);
|
||||
const existingSet = new Set(existingIds);
|
||||
const keptIds = new Set();
|
||||
|
||||
const insertZone = db.prepare(`
|
||||
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const updateZone = db.prepare(`
|
||||
UPDATE layout_zones SET name = ?, x_percent = ?, y_percent = ?, width_percent = ?,
|
||||
height_percent = ?, z_index = ?, zone_type = ?, fit_mode = ?, background_color = ?, sort_order = ?
|
||||
WHERE id = ? AND layout_id = ?
|
||||
`);
|
||||
|
||||
zones.forEach((z, i) => {
|
||||
stmt.run(z.id || uuidv4(), req.params.id, z.name || `Zone ${i + 1}`,
|
||||
const zid = z.id || uuidv4();
|
||||
const vals = [
|
||||
z.name || `Zone ${i + 1}`,
|
||||
z.x_percent || 0, z.y_percent || 0, z.width_percent || 100, z.height_percent || 100,
|
||||
z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'contain',
|
||||
z.background_color || '#000000', i);
|
||||
z.background_color || '#000000', i,
|
||||
];
|
||||
if (existingSet.has(zid)) updateZone.run(...vals, zid, req.params.id);
|
||||
else insertZone.run(zid, req.params.id, ...vals);
|
||||
keptIds.add(zid);
|
||||
});
|
||||
|
||||
// Only the zones the editor actually removed.
|
||||
for (const zid of existingIds) {
|
||||
if (!keptIds.has(zid)) {
|
||||
db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(zid, req.params.id);
|
||||
}
|
||||
}
|
||||
db.prepare('UPDATE layouts SET updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(req.params.id);
|
||||
}
|
||||
});
|
||||
|
|
@ -145,6 +183,22 @@ router.put('/:id', (req, res) => {
|
|||
|
||||
const updated = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
||||
updated.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(req.params.id);
|
||||
// Push to the displays using this layout. Editing a layout used to notify nothing at all, so a
|
||||
// zone change waited for the next heartbeat refresh at best — and on Android it did not apply
|
||||
// even then, because the rebuild was keyed on the layout ID, which does not change when you edit
|
||||
// a layout in place. Reported on #234 as "I added 4 zones and they dont appear on the screen".
|
||||
// The player-side fix makes the rebuild happen; this makes it happen promptly.
|
||||
try {
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||
const commandQueue = require('../lib/command-queue');
|
||||
for (const d of db.prepare('SELECT id FROM devices WHERE layout_id = ?').all(req.params.id)) {
|
||||
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@ const { db } = require('../db/database');
|
|||
// full-trust (a `web` overlay renders an arbitrary page in the player), so — like the
|
||||
// group command route — it requires the 'full' token scope. No-op for JWT sessions.
|
||||
const { requireScope } = require('../middleware/apiToken');
|
||||
const { accessContext } = require('../lib/tenancy');
|
||||
|
||||
// requireScope('full') gates API TOKENS and is a deliberate pass-through for JWT sessions
|
||||
// (middleware/apiToken.js: `if (!req.viaToken) return next()`). It was the ONLY guard on these
|
||||
// routes, so a dashboard session carried no write check at all here — every sibling
|
||||
// fleet-affecting route pairs the scope check with a role check (see device-groups.js, where
|
||||
// POST /:id/command is `requireScope('full'), requireGroupWrite`). This restores that pairing:
|
||||
// a read-only member is refused, exactly as they are on every other device mutation.
|
||||
function requireFleetWrite(req, res, next) {
|
||||
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context' });
|
||||
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.workspaceId);
|
||||
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
||||
if (!ctx) return res.status(403).json({ error: 'Access denied' });
|
||||
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
||||
return res.status(403).json({ error: 'Read-only access' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Reuse the existing 6-hex color contract (#RRGGBB). Overlay transparency is expressed
|
||||
// via the separate `opacity` field, so no alpha channel is accepted here.
|
||||
|
|
@ -82,7 +100,7 @@ function summarize(results) {
|
|||
}
|
||||
|
||||
// POST /api/pip — show an overlay on a device or group.
|
||||
router.post('/', requireScope('full'), (req, res) => {
|
||||
router.post('/', requireScope('full'), requireFleetWrite, (req, res) => {
|
||||
const b = req.body || {};
|
||||
|
||||
if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
|
||||
|
|
@ -157,7 +175,7 @@ function handleClear(req, res) {
|
|||
res.json({ success: true, target: targets.kind, ...summary });
|
||||
}
|
||||
|
||||
router.post('/clear', requireScope('full'), handleClear);
|
||||
router.delete('/', requireScope('full'), handleClear);
|
||||
router.post('/clear', requireScope('full'), requireFleetWrite, handleClear);
|
||||
router.delete('/', requireScope('full'), requireFleetWrite, handleClear);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ function buildSnapshotItems(playlistId) {
|
|||
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
|
||||
c.duration_sec as content_duration, c.remote_url, c.unstable_connection,
|
||||
c.captions_enabled, c.captions_lang, c.subtitle_url, c.subtitle_lang,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -216,7 +216,7 @@ router.get('/:id', requirePlaylistRead, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -277,7 +277,7 @@ router.post('/:id/publish', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -327,7 +327,7 @@ router.post('/:id/discard', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -339,7 +339,28 @@ router.post('/:id/discard', requirePlaylistWrite, (req, res) => {
|
|||
|
||||
// Delete playlist
|
||||
router.delete('/:id', requirePlaylistWrite, (req, res) => {
|
||||
// Which screens are about to lose their playlist — read BEFORE the delete, because
|
||||
// devices.playlist_id is ON DELETE SET NULL and the association is gone immediately after.
|
||||
const affected = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(req.params.id);
|
||||
|
||||
db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id);
|
||||
|
||||
// Tell them. The database detaches correctly, but nothing was emitted — so a screen kept showing
|
||||
// the deleted playlist until it happened to reconnect or was restarted. You delete a playlist to
|
||||
// take content off the wall; the wall carried on regardless. Every sibling mutation here already
|
||||
// pushes (publish, assign), and DELETE /devices/:id/playlist was given a push for exactly this
|
||||
// reason: "so the screen stops, rather than leaving the old content up".
|
||||
try {
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||
const commandQueue = require('../lib/command-queue');
|
||||
for (const d of affected) {
|
||||
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
|
@ -352,7 +373,7 @@ router.get('/:id/items', requirePlaylistRead, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -471,7 +492,7 @@ router.post('/:id/items', requirePlaylistWrite, async (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -555,7 +576,7 @@ router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -603,7 +624,7 @@ router.post('/:id/items/:itemId/duplicate', requirePlaylistWrite, (req, res) =>
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -632,7 +653,7 @@ router.post('/:id/items/reorder', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
|
|||
|
|
@ -94,6 +94,19 @@ function workspaceAccess(req, workspaceId) {
|
|||
// / layout / playlist refs (where workspace_id IS NULL is the platform-template
|
||||
// path and is always allowed) and for devices / device_groups (where
|
||||
// workspace_id is required - those tables never carry template rows).
|
||||
// layout_zones has no workspace_id of its own — a zone belongs to a layout, and the layout carries
|
||||
// the workspace. zone_id was the one polymorphic reference missing from the ownership checks, so a
|
||||
// schedule could be pointed at a zone in someone else's workspace.
|
||||
function checkZoneInWorkspace(zoneId, workspaceId) {
|
||||
const row = db.prepare(
|
||||
'SELECT l.workspace_id FROM layout_zones z JOIN layouts l ON l.id = z.layout_id WHERE z.id = ?'
|
||||
).get(zoneId);
|
||||
if (!row) return { status: 404, error: 'zone not found' };
|
||||
if (row.workspace_id === workspaceId) return null;
|
||||
if (row.workspace_id == null) return null; // platform-template layout
|
||||
return { status: 403, error: 'zone is not in this workspace' };
|
||||
}
|
||||
|
||||
function checkRefInWorkspace(table, id, workspaceId, opts = { allowNullWorkspace: false }) {
|
||||
const row = db.prepare(`SELECT workspace_id FROM ${table} WHERE id = ?`).get(id);
|
||||
if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` };
|
||||
|
|
@ -243,6 +256,33 @@ router.post('/', (req, res) => {
|
|||
const err = checkRefInWorkspace(table, id, targetWorkspaceId, { allowNullWorkspace: allowNull });
|
||||
if (err) return res.status(err.status).json({ error: err.error });
|
||||
}
|
||||
if (zone_id) {
|
||||
const zErr = checkZoneInWorkspace(zone_id, targetWorkspaceId);
|
||||
if (zErr) return res.status(zErr.status).json({ error: zErr.error });
|
||||
}
|
||||
|
||||
// A content-only schedule is turned into a playlist holding that one item.
|
||||
//
|
||||
// The dialog offers "Content (single item, optional)" and the value was stored faithfully — but
|
||||
// the engine only ever acts on layout_id and playlist_id, so content_id was read by nothing at
|
||||
// all. The schedule fired, changed nothing, and the calendar drew a block labelled with the
|
||||
// filename as confirmation that it would. Rather than add a third override path through the
|
||||
// engine and every player, give the item the same shape everything already understands: its own
|
||||
// playlist. That reuses the whole published/assign/push pipeline as-is.
|
||||
let effectivePlaylistId = playlist_id || null;
|
||||
if (!effectivePlaylistId && content_id) {
|
||||
const c = db.prepare('SELECT filename FROM content WHERE id = ?').get(content_id);
|
||||
const genId = uuidv4();
|
||||
db.prepare('INSERT INTO playlists (id, name, workspace_id, user_id, status) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(genId, `Scheduled: ${(c && c.filename) || 'item'}`, targetWorkspaceId, req.user.id, 'published');
|
||||
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, 0, 10)')
|
||||
.run(genId, content_id);
|
||||
// Publish through the shared path rather than hand-rolling the snapshot: players read
|
||||
// denormalized fields (filename, mime_type, filepath, remote_url, schedules...) out of
|
||||
// published_snapshot, and duplicating that shape here would rot the moment it changes.
|
||||
require('./playlists').publishPlaylist(genId);
|
||||
effectivePlaylistId = genId;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
|
|
@ -250,7 +290,7 @@ router.post('/', (req, res) => {
|
|||
start_time, end_time, timezone, recurrence, recurrence_end, priority, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
|
||||
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || targetTz || 'UTC',
|
||||
layout_id || null, effectivePlaylistId, title || '', start_time, end_time, timezone || targetTz || 'UTC',
|
||||
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
|
||||
|
||||
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id);
|
||||
|
|
@ -341,33 +381,58 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
|
|||
}
|
||||
|
||||
const recEnd = schedule.recurrence_end ? new Date(schedule.recurrence_end) : rangeEnd;
|
||||
let current = new Date(start);
|
||||
let count = 0;
|
||||
const maxIterations = 366;
|
||||
|
||||
while (current <= rangeEnd && current <= recEnd && count < maxIterations) {
|
||||
const instanceEnd = new Date(current.getTime() + durationMs);
|
||||
// Walk DAY BY DAY across the visible range and draw every day the rule actually fires.
|
||||
//
|
||||
// The old loop stepped by the recurrence unit from the schedule's original start, which got both
|
||||
// of the common presets wrong:
|
||||
// - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
|
||||
// FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule matched only its start day — one event a week, or
|
||||
// none at all if it had been created on a weekend.
|
||||
// - Starting from the original start with a 366-iteration cap meant a schedule begun more than
|
||||
// a year ago never reached the current week, so it drew nothing whatsoever.
|
||||
// The engine meanwhile evaluates day-of-week directly, so it ran Mon-Fri regardless. The calendar
|
||||
// is the operator's only view of what is scheduled, and it disagreed with reality in both
|
||||
// directions. Iterating the range instead means the drawing follows the same rule the engine
|
||||
// applies, and the cost is bounded by the window being displayed rather than by history.
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const startTimeOfDay = { h: start.getHours(), m: start.getMinutes(), s: start.getSeconds() };
|
||||
|
||||
if (current >= rangeStart || instanceEnd >= rangeStart) {
|
||||
const dayOfWeek = current.getDay();
|
||||
const matchesDay = !rule.byDay || rule.byDay.includes(dayOfWeek);
|
||||
// First candidate day: the later of the schedule's start and the window's start.
|
||||
let cursor = new Date(Math.max(start.getTime(), rangeStart.getTime()));
|
||||
cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0);
|
||||
if (cursor.getTime() + durationMs < rangeStart.getTime()) cursor = new Date(cursor.getTime() + dayMs);
|
||||
|
||||
if (matchesDay) {
|
||||
events.push({
|
||||
...schedule,
|
||||
instance_start: current.toISOString(),
|
||||
instance_end: instanceEnd.toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
const lastDay = new Date(Math.min(rangeEnd.getTime(), recEnd.getTime()));
|
||||
const interval = Math.max(1, rule.interval || 1);
|
||||
|
||||
while (cursor <= lastDay) {
|
||||
const instanceEnd = new Date(cursor.getTime() + durationMs);
|
||||
let fires = false;
|
||||
switch (rule.freq) {
|
||||
case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break;
|
||||
case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break;
|
||||
case 'MONTHLY': current.setMonth(current.getMonth() + (rule.interval || 1)); break;
|
||||
default: current.setDate(current.getDate() + 1);
|
||||
case 'DAILY':
|
||||
// Honour the interval by counting whole days from the original start.
|
||||
fires = Math.floor((cursor - start) / dayMs) % interval === 0;
|
||||
break;
|
||||
case 'WEEKLY':
|
||||
// byDay is what makes Mon-Fri work. Without it, weekly means "the start's weekday".
|
||||
fires = rule.byDay ? rule.byDay.includes(cursor.getDay()) : cursor.getDay() === start.getDay();
|
||||
break;
|
||||
case 'MONTHLY':
|
||||
fires = cursor.getDate() === start.getDate();
|
||||
break;
|
||||
default:
|
||||
fires = true;
|
||||
}
|
||||
count++;
|
||||
if (fires && (cursor >= rangeStart || instanceEnd >= rangeStart)) {
|
||||
events.push({
|
||||
...schedule,
|
||||
instance_start: cursor.toISOString(),
|
||||
instance_end: instanceEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
cursor = new Date(cursor.getTime() + dayMs);
|
||||
cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0); // DST-safe re-anchor
|
||||
}
|
||||
|
||||
return events;
|
||||
|
|
@ -393,3 +458,6 @@ function parseRRule(rrule) {
|
|||
}
|
||||
|
||||
module.exports = router;
|
||||
// Exported for testing, the same way playlists.js exports publishPlaylist. The calendar's
|
||||
// correctness is arithmetic and deserves to be checked without standing up a server.
|
||||
module.exports.expandSchedule = expandSchedule;
|
||||
|
|
|
|||
|
|
@ -157,6 +157,30 @@ router.put('/:id', (req, res) => {
|
|||
if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
|
||||
if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||
|
||||
// Push the change to any display currently showing this widget. Editing a widget used to
|
||||
// notify nothing at all: the render endpoint serves live config, but a player that already has
|
||||
// the widget on screen keeps its WebView (deliberately — re-navigating a widget every duration
|
||||
// is a visible flash and destroys widget state). With no push and no change to the URL, an edit
|
||||
// reached the screen only when the app was restarted. Reported on #234: "I changed the text and
|
||||
// the new text did not appear on the screen. I had to close the app and then open again."
|
||||
//
|
||||
// The push is what makes it prompt; the rev in the payload is what makes the player reload.
|
||||
try {
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||
const commandQueue = require('../lib/command-queue');
|
||||
const affected = db.prepare(`
|
||||
SELECT DISTINCT d.id FROM devices d
|
||||
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
|
||||
WHERE pi.widget_id = ?
|
||||
`).all(req.params.id);
|
||||
for (const d of affected) {
|
||||
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
|
||||
|
||||
res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
|
|
@ -196,9 +220,20 @@ router.get('/:id/render', (req, res) => {
|
|||
// widgets render blank in the web player. Drop it here; the sandbox - not
|
||||
// X-Frame-Options - is what isolates the widget (it can't read the dashboard JWT).
|
||||
res.removeHeader('X-Frame-Options');
|
||||
// Never cache the render: widget data (clock/weather/rss/directory) changes, and
|
||||
// a cached copy from before the X-Frame-Options change would keep showing blank.
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
// Caching is keyed on whether the caller pinned a revision.
|
||||
//
|
||||
// A URL carrying ?rev=<widget.updated_at> is content-addressed: those exact bytes cannot change
|
||||
// without the rev changing, so it is safe to cache hard — and it NEEDS to be, because a player
|
||||
// that loses its network must still be able to render its widgets. Offline resilience is the
|
||||
// point of the player's cache, and no-store made widgets the one thing it could never keep.
|
||||
//
|
||||
// A URL with no rev is the old shape and stays uncacheable: nothing distinguishes one render
|
||||
// from the next, so a cached copy could serve content the operator has already changed.
|
||||
if (req.query.rev) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(renderWidgetHtml(widget.widget_type, config));
|
||||
});
|
||||
|
|
@ -399,12 +434,90 @@ load(); setInterval(load, 300000);
|
|||
}
|
||||
|
||||
function renderText(c) {
|
||||
// Designer preview uses fontSize/10 vw, but older published HTML used fontSize*10.8 px.
|
||||
// Convert any px-based font sizes to vw so they scale to any viewport: px / 108 = vw
|
||||
let html = c.html || '<p style="color:white;padding:20px">Empty text widget</p>';
|
||||
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
|
||||
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
|
||||
});
|
||||
|
||||
// LEGACY DESIGNER RESCUE — deliberately narrow.
|
||||
//
|
||||
// The Content Designer used to publish absolute font sizes as fontSize*10.8 px; today it emits
|
||||
// cqw (see designer.js). Converting px/108 back to vw restores the author's intended size and
|
||||
// makes those old widgets scale to any screen.
|
||||
//
|
||||
// It must NOT touch hand-authored HTML. This regex used to run over EVERY text widget, so
|
||||
// someone writing `font-size:16px` in the Text/HTML editor got 0.15vw — 2.8px on a 1080p
|
||||
// screen, and smaller still on anything narrower. Their text was not clipped or hidden; it was
|
||||
// rendered too small to read, in the one widget whose whole purpose is hand-written HTML.
|
||||
//
|
||||
// Designer output is identified by its absolutely-positioned elements, the same signal the
|
||||
// dashboard uses to decide whether a text widget can be reopened in the designer. Hand-written
|
||||
// markup keeps its px exactly as typed.
|
||||
const isDesignerAuthored = /position:\s*absolute;\s*left:/.test(html);
|
||||
if (isDesignerAuthored) {
|
||||
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
|
||||
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
|
||||
});
|
||||
}
|
||||
|
||||
// What to do when the text is taller than the screen. It used to be clipped in silence: the
|
||||
// document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display
|
||||
// shorter than the content the bottom simply vanished — reported as "text goes to bottom and
|
||||
// disappears. It dont fit."
|
||||
//
|
||||
// fit (default) shrink until it fits. A no-op when the content already fits, so this
|
||||
// rescues widgets that are currently losing text without altering ones that are fine.
|
||||
// scroll pan through it on a loop, with a pause at each end. For content that is genuinely
|
||||
// longer than a screen, where shrinking it would make it unreadable.
|
||||
// clip the old behaviour, kept because a designer-positioned layout may deliberately run
|
||||
// past the edge and must not be rescaled underneath the author.
|
||||
const overflowMode = ['fit', 'scroll', 'clip'].includes(c.overflow) ? c.overflow : 'fit';
|
||||
|
||||
// Runs inside the sandboxed iframe (allow-scripts, null origin). Measures after layout, after
|
||||
// web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts
|
||||
// loading late is the classic cause of a fit that was computed against the wrong height.
|
||||
const fitScript = overflowMode === 'clip' ? '' : `<script>
|
||||
(function () {
|
||||
var mode = ${JSON.stringify(overflowMode)};
|
||||
var wrap = document.getElementById('st-wrap');
|
||||
if (!wrap) return;
|
||||
var anim = null;
|
||||
function apply() {
|
||||
// Reset before measuring, or we measure the previous transform's result.
|
||||
wrap.style.transform = '';
|
||||
if (anim) { anim.cancel(); anim = null; }
|
||||
var avail = document.documentElement.clientHeight;
|
||||
var need = wrap.scrollHeight;
|
||||
if (!avail || !need || need <= avail + 1) return; // already fits: leave it alone
|
||||
if (mode === 'fit') {
|
||||
var k = avail / need;
|
||||
wrap.style.transformOrigin = 'top center';
|
||||
wrap.style.transform = 'scale(' + k + ')';
|
||||
return;
|
||||
}
|
||||
// scroll: hold, pan the overflow, hold, return. Speed is distance-based so a long
|
||||
// document is not unreadably fast and a short one is not tediously slow.
|
||||
var over = need - avail;
|
||||
var panMs = Math.max(4000, (over / 40) * 1000);
|
||||
var holdMs = 2000;
|
||||
var total = panMs * 2 + holdMs * 2;
|
||||
var p1 = holdMs / total, p2 = (holdMs + panMs) / total, p3 = (holdMs * 2 + panMs) / total;
|
||||
anim = wrap.animate(
|
||||
[
|
||||
{ transform: 'translateY(0)', offset: 0 },
|
||||
{ transform: 'translateY(0)', offset: p1 },
|
||||
{ transform: 'translateY(' + (-over) + 'px)', offset: p2 },
|
||||
{ transform: 'translateY(' + (-over) + 'px)', offset: p3 },
|
||||
{ transform: 'translateY(0)', offset: 1 },
|
||||
],
|
||||
{ duration: total, iterations: Infinity, easing: 'linear' }
|
||||
);
|
||||
}
|
||||
addEventListener('resize', apply);
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(apply).catch(function(){});
|
||||
// Late images change the height too; rAF lets first layout finish before measuring.
|
||||
addEventListener('load', function () { requestAnimationFrame(apply); });
|
||||
requestAnimationFrame(apply);
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
// Security: c.html / c.css are intentionally raw user-authored content, but the
|
||||
// render is public and same-origin with the dashboard - injected <script> could
|
||||
// otherwise read the dashboard's localStorage JWT. Render the user content inside
|
||||
|
|
@ -413,8 +526,11 @@ function renderText(c) {
|
|||
const inner = `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { width:100vw; height:100vh; overflow:hidden; }
|
||||
/* The wrapper is what gets scaled or panned. It must be allowed to exceed the viewport,
|
||||
otherwise there is nothing to measure and nothing to move. */
|
||||
#st-wrap { width:100%; min-height:100%; will-change:transform; }
|
||||
${c.css || ''}
|
||||
</style></head><body>${html}</body></html>`;
|
||||
</style></head><body><div id="st-wrap">${html}</div>${fitScript}</body></html>`;
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; }
|
||||
html, body { width:100vw; height:100vh; overflow:hidden; background:${safeCss(c.background, 'transparent')}; }
|
||||
|
|
|
|||
|
|
@ -144,6 +144,18 @@ function isScheduleActiveNow(schedule, now, tz) {
|
|||
const rule = parseSimpleRRule(schedule.recurrence);
|
||||
if (!rule) return nowStamp >= startStamp && nowStamp <= endStamp;
|
||||
|
||||
// The DATE window. A recurring schedule was previously compared on weekday and HH:MM alone, with
|
||||
// the date component dropped entirely — so it was live before its start date and, more visibly,
|
||||
// carried on forever after its end date. A campaign set to finish on the 1st was still switching
|
||||
// screens weeks later, while the calendar (which does read recurrence_end) showed it as stopped.
|
||||
// The end date is offered on the form; it has to mean something.
|
||||
const nowDate = nowStamp.slice(0, 10);
|
||||
if (nowDate < startStamp.slice(0, 10)) return false; // has not begun yet
|
||||
if (schedule.recurrence_end) {
|
||||
// Inclusive: an end date of the 5th means the 5th still runs, to its normal end time.
|
||||
if (nowDate > String(schedule.recurrence_end).slice(0, 10)) return false;
|
||||
}
|
||||
|
||||
// Day-of-week in the device's local zone.
|
||||
if (rule.byDay && !rule.byDay.includes(L.dow)) return false;
|
||||
|
||||
|
|
@ -174,3 +186,6 @@ function pushPlaylistToDevice(deviceId, deviceNs) {
|
|||
}
|
||||
|
||||
module.exports = { startScheduler, pushPlaylistToDevice, rebootDue };
|
||||
// Exported for testing: whether a schedule is live right now is the single decision this service
|
||||
// exists to make, and it should be checkable without a ticking timer.
|
||||
module.exports.isScheduleActiveNow = isScheduleActiveNow;
|
||||
|
|
|
|||
82
server/test/device-info-empty-refresh.test.js
Normal file
82
server/test/device-info-empty-refresh.test.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
'use strict';
|
||||
|
||||
// Every web and BrightSign player nulled seventeen of its own device columns every five minutes.
|
||||
//
|
||||
// The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has
|
||||
// nothing new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is
|
||||
// a blind full-row overwrite with no per-field presence check, so it bound `undefined` for every
|
||||
// column. better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and
|
||||
// the row was quietly emptied: version, resolution, render size, OTA state, tier, the capability
|
||||
// flags and the volume/brightness columns.
|
||||
//
|
||||
// Android was unaffected because it always sends the full object — so this only ever degraded the
|
||||
// client family that has no other way to be inspected. Fleet view, resolution diagnostics and any
|
||||
// version-based logic read blank for them.
|
||||
//
|
||||
// The surrounding code already anticipates this shape: recordReconnect and persistIdentity are
|
||||
// gated behind `if (!isPlaylistRefresh)`. This one call was not.
|
||||
//
|
||||
// The invariant: an empty device_info means "nothing new", never "forget what you know".
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-devinfo-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
|
||||
const { db } = require('../db/database');
|
||||
|
||||
const WS = 'ws-di', O = 'o-di', U = 'u-di', DEV = 'dev-di';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash) VALUES (?,?, 'x')").run(U, 'di@t.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
||||
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,android_version,app_version,screen_width,screen_height,created_at,updated_at)
|
||||
VALUES (?, 'Web Screen', ?, 'Web/Chrome', '1.1.0-web', 1920, 1080, strftime('%s','now'), strftime('%s','now'))`).run(DEV, WS);
|
||||
|
||||
const row = () => db.prepare('SELECT android_version, app_version, screen_width, screen_height FROM devices WHERE id = ?').get(DEV);
|
||||
|
||||
// The guard as the socket handler applies it.
|
||||
const shouldApply = (deviceInfo) => !!(deviceInfo && Object.keys(deviceInfo).length > 0);
|
||||
|
||||
test('THE BUG: an empty device_info must not be treated as new information', () => {
|
||||
// `{}` is truthy — that is the whole trap.
|
||||
assert.equal(!!{}, true, 'this is why the old `if (device_info)` let it through');
|
||||
assert.equal(shouldApply({}), false, 'but it carries nothing, so nothing should be written');
|
||||
});
|
||||
|
||||
test('undefined really does become NULL rather than throwing, so the write did succeed', () => {
|
||||
// Pinning the driver behaviour the bug depended on: had it thrown, this would have been loud
|
||||
// instead of a silent five-minutely wipe.
|
||||
const before = row();
|
||||
assert.equal(before.app_version, '1.1.0-web');
|
||||
db.prepare('UPDATE devices SET app_version = ? WHERE id = ?').run(undefined, DEV);
|
||||
assert.equal(row().app_version, null, 'silently nulled — no error, no warning');
|
||||
db.prepare('UPDATE devices SET app_version = ? WHERE id = ?').run('1.1.0-web', DEV);
|
||||
});
|
||||
|
||||
test('a refresh-register leaves what we already know intact', () => {
|
||||
const before = row();
|
||||
if (shouldApply({})) throw new Error('guard failed'); // the handler would skip the write
|
||||
const after = row();
|
||||
assert.deepEqual(after, before, 'version and resolution must survive a refresh beat');
|
||||
});
|
||||
|
||||
test('a real device_info is still applied', () => {
|
||||
const info = { app_version: '1.9.28', screen_width: 3840 };
|
||||
assert.equal(shouldApply(info), true);
|
||||
db.prepare('UPDATE devices SET app_version = ?, screen_width = ? WHERE id = ?')
|
||||
.run(info.app_version, info.screen_width, DEV);
|
||||
const after = row();
|
||||
assert.equal(after.app_version, '1.9.28');
|
||||
assert.equal(after.screen_width, 3840);
|
||||
});
|
||||
|
||||
test('a missing device_info is skipped too', () => {
|
||||
assert.equal(shouldApply(undefined), false);
|
||||
assert.equal(shouldApply(null), false);
|
||||
});
|
||||
|
||||
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
96
server/test/device-settings-workspace-confined.test.js
Normal file
96
server/test/device-settings-workspace-confined.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
'use strict';
|
||||
|
||||
// Per-device settings are saved against the hardware FINGERPRINT so a panel that is deleted and
|
||||
// paired again comes back configured — its name, orientation, playlist and blocked flag restored
|
||||
// without anyone visiting it. That is deliberate and useful.
|
||||
//
|
||||
// A fingerprint is hardware-derived, so the same physical panel presents the same one no matter
|
||||
// whose account it is paired into. applyToDevice looked the snapshot up on fingerprint alone with
|
||||
// no workspace comparison, and its per-field guards only check that the referenced row still
|
||||
// EXISTS, never who it belongs to:
|
||||
//
|
||||
// if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))
|
||||
//
|
||||
// So a screen removed from one workspace and paired into another inherited the first workspace's
|
||||
// playlist and started displaying its content. `blocked` crossed the same way, giving a device that
|
||||
// arrives blocked for no reason the new owner can see. The manual restore route already compares
|
||||
// workspaces before calling this, so the automatic re-pair path was the one place it was missing.
|
||||
//
|
||||
// The invariant: a saved snapshot only ever applies inside the workspace it was taken in.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-ws-confine-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
|
||||
const { db } = require('../db/database');
|
||||
const deviceSettings = require('../lib/device-settings');
|
||||
|
||||
function seedWorkspace(tag) {
|
||||
const u = 'u-' + tag, o = 'o-' + tag, ws = 'ws-' + tag, pl = 'pl-' + tag;
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash) VALUES (?,?, 'x')").run(u, tag + '@t.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(o, 'org ' + tag, u);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(ws, o, 'ws ' + tag);
|
||||
db.prepare('INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES (?,?,?,?)').run(pl, 'PL ' + tag, ws, u);
|
||||
return { u, ws, pl };
|
||||
}
|
||||
|
||||
const A = seedWorkspace('alpha');
|
||||
const B = seedWorkspace('bravo');
|
||||
const FP = 'hardware-fingerprint-shared';
|
||||
|
||||
function makeDevice(id, ws) {
|
||||
db.prepare(`INSERT OR REPLACE INTO devices (id,name,workspace_id,created_at,updated_at)
|
||||
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(id, 'Screen', ws);
|
||||
return id;
|
||||
}
|
||||
const deviceRow = (id) => db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
|
||||
|
||||
// The panel lived in workspace A: named, assigned A's playlist, and blocked there.
|
||||
db.prepare(`INSERT OR REPLACE INTO device_settings (fingerprint, workspace_id, device_name, playlist_id, blocked, last_seen)
|
||||
VALUES (?,?, 'Lobby Screen', ?, 1, strftime('%s','now'))`).run(FP, A.ws, A.pl);
|
||||
|
||||
test('THE LEAK: a panel paired into another workspace does not inherit the first ones playlist', () => {
|
||||
const dev = makeDevice('dev-in-B', B.ws);
|
||||
deviceSettings.applyToDevice(dev, FP);
|
||||
const d = deviceRow(dev);
|
||||
assert.equal(d.playlist_id, null, "workspace B's screen must not be playing workspace A's content");
|
||||
assert.equal(d.workspace_id, B.ws, 'and it must stay in its own workspace');
|
||||
});
|
||||
|
||||
test('a block from another workspace does not follow the hardware either', () => {
|
||||
const dev = makeDevice('dev-block-B', B.ws);
|
||||
deviceSettings.applyToDevice(dev, FP);
|
||||
assert.equal(deviceRow(dev).blocked, 0, 'arriving blocked with nothing to explain it is unactionable');
|
||||
});
|
||||
|
||||
test('a mismatch is a quiet no-op, because re-pairing a second-hand panel is legitimate', () => {
|
||||
// It must not throw or refuse the pairing — only decline to carry the old configuration.
|
||||
const dev = makeDevice('dev-noop-B', B.ws);
|
||||
assert.doesNotThrow(() => deviceSettings.applyToDevice(dev, FP));
|
||||
assert.equal(deviceRow(dev).name, 'Screen', 'the name from the other workspace must not be applied');
|
||||
});
|
||||
|
||||
test('AND THE POINT OF THE FEATURE: restore still works inside the owning workspace', () => {
|
||||
// The whole reason this exists — a panel re-paired at home comes back configured.
|
||||
const dev = makeDevice('dev-in-A', A.ws);
|
||||
deviceSettings.applyToDevice(dev, FP);
|
||||
const d = deviceRow(dev);
|
||||
assert.equal(d.playlist_id, A.pl, 'its own playlist must be restored');
|
||||
assert.equal(d.name, 'Lobby Screen', 'its own name must be restored');
|
||||
assert.equal(d.blocked, 1, 'and a genuine block must still survive a re-pair');
|
||||
});
|
||||
|
||||
test('a snapshot with no workspace recorded is still applied, so legacy rows keep working', () => {
|
||||
db.prepare(`INSERT OR REPLACE INTO device_settings (fingerprint, workspace_id, device_name, blocked, last_seen)
|
||||
VALUES ('legacy-fp', NULL, 'Legacy Name', 0, strftime('%s','now'))`).run();
|
||||
const dev = makeDevice('dev-legacy', B.ws);
|
||||
deviceSettings.applyToDevice(dev, 'legacy-fp');
|
||||
assert.equal(deviceRow(dev).name, 'Legacy Name');
|
||||
});
|
||||
|
||||
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
|
|
@ -61,13 +61,24 @@ test('THE POINT: it is only finished when something is actually ON a screen', as
|
|||
assert.equal(done.nextIndex, -1);
|
||||
});
|
||||
|
||||
test('any of the three ways of assigning counts', async () => {
|
||||
for (const d of [{ id: 'x', playlist_id: 'p' }, { id: 'x', default_content_id: 'c' }, { id: 'x', layout_id: 'l' }]) {
|
||||
test('assigning a playlist or a layout counts', async () => {
|
||||
for (const d of [{ id: 'x', playlist_id: 'p' }, { id: 'x', layout_id: 'l' }]) {
|
||||
const s = GS.computeSteps({ devices: [d], content: [{ id: 'c' }], playlists: [{ id: 'p' }] });
|
||||
assert.equal(s.complete, true, `${Object.keys(d).join(',')} should count as assigned`);
|
||||
}
|
||||
});
|
||||
|
||||
test('default_content_id does NOT count, because no player reads it', async () => {
|
||||
// This test previously asserted the opposite, and it was wrong. Grep the whole tree and
|
||||
// default_content appears only in this checklist, the device form, the settings snapshot, the
|
||||
// schema and the devices route — never in a socket payload, in assemblePayload, or in any of the
|
||||
// four players. Setting it changes nothing on the screen, so counting it told the operator
|
||||
// "content assigned" while their display went on showing "waiting for content". A checklist that
|
||||
// lies about the one thing it exists to confirm is worse than no checklist.
|
||||
const s = GS.computeSteps({ devices: [{ id: 'x', default_content_id: 'c' }], content: [{ id: 'c' }], playlists: [{ id: 'p' }] });
|
||||
assert.equal(s.complete, false, 'a screen with only default_content is not actually showing anything');
|
||||
});
|
||||
|
||||
test('steps stay in dependency order — never sent somewhere unusable', async () => {
|
||||
// Content before a screen exists is not wrong, but it cannot be PUT anywhere, so the next
|
||||
// action must remain the screen.
|
||||
|
|
|
|||
100
server/test/group-delete-schedule-conversion.test.js
Normal file
100
server/test/group-delete-schedule-conversion.test.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
'use strict';
|
||||
|
||||
// Deleting a device group converts its group schedules into per-device ones so the screens keep
|
||||
// their programming. That INSERT omitted workspace_id, which is nullable with no default — so every
|
||||
// converted row landed with workspace_id = NULL.
|
||||
//
|
||||
// A null workspace does not merely look untidy. It makes the row unreachable in three directions at
|
||||
// once, and they compound into the worst possible combination:
|
||||
//
|
||||
// invisible — the schedule list and the all-screens calendar both filter on workspace_id
|
||||
// undeletable — PUT and DELETE refuse a row with no workspace (403)
|
||||
// still live — services/scheduler.js has NO workspace filter, so it keeps firing every 60s
|
||||
//
|
||||
// i.e. "I deleted the group but the screens still switch content at 9am, and there is nothing in
|
||||
// the calendar to remove." The only way out was direct database access.
|
||||
//
|
||||
// The invariant: a schedule that survives a group deletion stays owned by a workspace, so it can be
|
||||
// seen and removed by the person whose screens it controls.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-groupdel-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-group-delete';
|
||||
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||
const { resolveTenancy } = require('../lib/tenancy');
|
||||
|
||||
const O = 'o-gd', WS = 'ws-gd', U = 'u-gd', G = 'g-gd', DEV = 'dev-gd';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'gd@t.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
||||
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
|
||||
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
|
||||
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS);
|
||||
db.prepare('INSERT OR IGNORE INTO device_groups (id,name,workspace_id,user_id) VALUES (?,?,?,?)').run(G, 'Group', WS, U);
|
||||
db.prepare('INSERT OR IGNORE INTO device_group_members (group_id,device_id) VALUES (?,?)').run(G, DEV);
|
||||
db.prepare(`INSERT OR IGNORE INTO schedules (id,user_id,workspace_id,group_id,title,start_time,end_time,timezone,priority,enabled)
|
||||
VALUES ('sg-1',?,?,?, 'Morning menu','09:00','17:00','UTC',1,1)`).run(U, WS, G);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.set('io', null);
|
||||
app.use('/api/groups', requireAuth, resolveTenancy, require('../routes/device-groups'));
|
||||
const server = app.listen(0);
|
||||
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
|
||||
|
||||
async function deleteGroup() {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/groups/${G}`, {
|
||||
method: 'DELETE', headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
test('THE BUG: a schedule that survives a group deletion keeps its workspace', async () => {
|
||||
const { status, body } = await deleteGroup();
|
||||
assert.equal(status, 200);
|
||||
assert.ok(body.schedules_converted >= 1, 'the schedule should have been converted, not dropped');
|
||||
|
||||
const converted = db.prepare('SELECT * FROM schedules WHERE device_id = ? AND group_id IS NULL').all(DEV);
|
||||
assert.equal(converted.length, 1);
|
||||
assert.equal(converted[0].workspace_id, WS, 'a null workspace makes the row invisible AND undeletable AND live');
|
||||
});
|
||||
|
||||
test('the converted schedule is therefore visible to the workspace it controls', () => {
|
||||
// This is the query the schedule list and the calendar both use.
|
||||
const visible = db.prepare('SELECT COUNT(*) n FROM schedules WHERE workspace_id = ? AND device_id = ?').get(WS, DEV).n;
|
||||
assert.equal(visible, 1);
|
||||
});
|
||||
|
||||
test('the programming itself is preserved, not just the ownership', () => {
|
||||
const s = db.prepare('SELECT * FROM schedules WHERE device_id = ? AND group_id IS NULL').get(DEV);
|
||||
assert.equal(s.title, 'Morning menu');
|
||||
assert.equal(s.start_time, '09:00');
|
||||
assert.equal(s.end_time, '17:00');
|
||||
assert.equal(s.enabled, 1);
|
||||
});
|
||||
|
||||
test('the repair recovers rows orphaned before this fix existed', () => {
|
||||
// Simulate the old behaviour, then run the same statement the boot migration runs.
|
||||
db.prepare(`INSERT INTO schedules (id,user_id,workspace_id,device_id,title,start_time,end_time,timezone,priority,enabled)
|
||||
VALUES ('legacy-orphan',?,NULL,?, 'Orphan','06:00','08:00','UTC',1,1)`).run(U, DEV);
|
||||
assert.equal(db.prepare("SELECT workspace_id FROM schedules WHERE id='legacy-orphan'").get().workspace_id, null);
|
||||
|
||||
db.prepare(`UPDATE schedules SET workspace_id = (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id)
|
||||
WHERE workspace_id IS NULL AND device_id IS NOT NULL
|
||||
AND (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id) IS NOT NULL`).run();
|
||||
|
||||
assert.equal(db.prepare("SELECT workspace_id FROM schedules WHERE id='legacy-orphan'").get().workspace_id, WS,
|
||||
'an operator must be able to see and delete it from the dashboard');
|
||||
});
|
||||
|
||||
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
114
server/test/layout-save-preserves-bindings.test.js
Normal file
114
server/test/layout-save-preserves-bindings.test.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
'use strict';
|
||||
|
||||
// Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data.
|
||||
//
|
||||
// The handler deleted every zone and re-inserted the same ids, and its comment claimed that was
|
||||
// safe: "Reuse each zone's id when supplied so device->zone assignments survive an edit." It is
|
||||
// not. SQLite runs referential actions on the DELETE, and re-inserting the same primary key does
|
||||
// not resurrect what they took with them:
|
||||
//
|
||||
// playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist item un-assigned,
|
||||
// so those playlists silently fell back to
|
||||
// fullscreen across the workspace
|
||||
// schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule DELETED, permanently
|
||||
//
|
||||
// 200 OK, no warning, no undo. The invariant: saving a layout must not change anything that merely
|
||||
// POINTS at its zones — only removing a zone may do that, which is what the cascades are for.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-layoutsave-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-layout-save';
|
||||
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||
const { resolveTenancy } = require('../lib/tenancy');
|
||||
|
||||
const O = 'o-ls', WS = 'ws-ls', U = 'u-ls', L = 'l-ls', PL = 'pl-ls';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'ls@t.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
||||
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
|
||||
db.prepare('INSERT OR IGNORE INTO layouts (id,workspace_id,user_id,name,width,height) VALUES (?,?,?,?,1920,1080)').run(L, WS, U, 'L');
|
||||
db.prepare("INSERT OR IGNORE INTO layout_zones (id,layout_id,name,x_percent,y_percent,width_percent,height_percent,z_index,zone_type,fit_mode,background_color,sort_order) VALUES ('z-a',?,'A',0,0,100,50,1,'content','contain','#000000',0)").run(L);
|
||||
db.prepare("INSERT OR IGNORE INTO layout_zones (id,layout_id,name,x_percent,y_percent,width_percent,height_percent,z_index,zone_type,fit_mode,background_color,sort_order) VALUES ('z-b',?,'B',0,50,100,50,2,'content','contain','#000000',1)").run(L);
|
||||
db.prepare('INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES (?,?,?,?)').run(PL, 'PL', WS, U);
|
||||
db.prepare("INSERT OR IGNORE INTO content (id,workspace_id,user_id,filename,filepath,mime_type,file_size) VALUES ('c-1',?,?,'a.jpg','a.jpg','image/jpeg',10)").run(WS, U);
|
||||
// playlist_items.id is an INTEGER rowid, so let SQLite assign it and remember what it gave us.
|
||||
const ITEM_ID = db.prepare("INSERT INTO playlist_items (playlist_id,content_id,zone_id,sort_order,duration_sec) VALUES (?, 'c-1','z-a',0,10)").run(PL).lastInsertRowid;
|
||||
// A schedule must target exactly one of device_id / group_id (CHECK constraint), so give it a device.
|
||||
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
|
||||
VALUES ('dev-ls','Screen',?,strftime('%s','now'),strftime('%s','now'))`).run(WS);
|
||||
db.prepare(`INSERT OR IGNORE INTO schedules (id,user_id,workspace_id,device_id,layout_id,zone_id,title,start_time,end_time,timezone,priority,enabled)
|
||||
VALUES ('s-1',?,?, 'dev-ls',?, 'z-a','Zone schedule','09:00','17:00','UTC',1,1)`).run(U, WS, L);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.set('io', null);
|
||||
app.use('/api/layouts', requireAuth, resolveTenancy, require('../routes/layouts'));
|
||||
const server = app.listen(0);
|
||||
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
|
||||
|
||||
async function saveZones(zones) {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/layouts/${L}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ zones }),
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
const itemZone = () => db.prepare('SELECT zone_id FROM playlist_items WHERE id = ?').get(ITEM_ID).zone_id;
|
||||
const scheduleExists = () => !!db.prepare("SELECT 1 FROM schedules WHERE id = 's-1'").get();
|
||||
const zoneIds = () => db.prepare('SELECT id FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(L).map(r => r.id);
|
||||
|
||||
const CURRENT = () => [
|
||||
{ id: 'z-a', name: 'A', x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 50, z_index: 1, zone_type: 'content', fit_mode: 'contain' },
|
||||
{ id: 'z-b', name: 'B', x_percent: 0, y_percent: 50, width_percent: 100, height_percent: 50, z_index: 2, zone_type: 'content', fit_mode: 'contain' },
|
||||
];
|
||||
|
||||
test('THE BUG: moving a zone must not un-assign playlist items or delete schedules', async () => {
|
||||
assert.equal(itemZone(), 'z-a', 'precondition');
|
||||
assert.equal(scheduleExists(), true, 'precondition');
|
||||
|
||||
const zones = CURRENT();
|
||||
zones[0].y_percent = 2; // the "nudge"
|
||||
assert.equal(await saveZones(zones), 200);
|
||||
|
||||
assert.equal(itemZone(), 'z-a', 'the item must still be in its zone');
|
||||
assert.equal(scheduleExists(), true, 'the zone-bound schedule must still exist');
|
||||
});
|
||||
|
||||
test('the geometry change is actually applied', async () => {
|
||||
const z = db.prepare("SELECT y_percent FROM layout_zones WHERE id = 'z-a'").get();
|
||||
assert.equal(z.y_percent, 2);
|
||||
});
|
||||
|
||||
test('adding a zone leaves existing bindings alone', async () => {
|
||||
const zones = CURRENT();
|
||||
zones[0].y_percent = 2;
|
||||
zones.push({ name: 'C', x_percent: 0, y_percent: 90, width_percent: 100, height_percent: 10, z_index: 3, zone_type: 'content', fit_mode: 'contain' });
|
||||
assert.equal(await saveZones(zones), 200);
|
||||
assert.equal(zoneIds().length, 3);
|
||||
assert.equal(itemZone(), 'z-a');
|
||||
assert.equal(scheduleExists(), true);
|
||||
});
|
||||
|
||||
test('REMOVING a zone still cascades — that is what the cascades are for', async () => {
|
||||
// The fix must not turn deletion into a no-op: dropping the zone an item lives in should
|
||||
// un-assign that item and take its schedules with it.
|
||||
const zones = CURRENT().filter(z => z.id !== 'z-a');
|
||||
assert.equal(await saveZones(zones), 200);
|
||||
assert.ok(!zoneIds().includes('z-a'), 'the removed zone is gone');
|
||||
assert.equal(itemZone(), null, 'its item is un-assigned');
|
||||
assert.equal(scheduleExists(), false, 'its schedule is removed');
|
||||
});
|
||||
|
||||
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
99
server/test/pip-write-authorization.test.js
Normal file
99
server/test/pip-write-authorization.test.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
'use strict';
|
||||
|
||||
// A PiP overlay is pushed to a live screen and can render an arbitrary web page across it, at full
|
||||
// resolution, for as long as the operator wants (duration 0 = persistent). That is a fleet-affecting
|
||||
// write and must be held to the same bar as every other one.
|
||||
//
|
||||
// It was not. The only guard was requireScope('full'), which gates API TOKENS and is a deliberate
|
||||
// pass-through for dashboard sessions (`if (!req.viaToken) return next()`). Every sibling route
|
||||
// pairs that scope check with a role check — device-groups.js gates POST /:id/command with
|
||||
// `requireScope('full'), requireGroupWrite` — but these three routes had only the half that does
|
||||
// nothing for a logged-in user. A member whose role is read-only everywhere else could push and
|
||||
// clear overlays on every screen in the workspace.
|
||||
//
|
||||
// The invariant: a read-only member cannot change what a screen displays. Pinned for all three
|
||||
// write routes, because a partial fix here is worthless.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-pip-authz-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-pip-authz';
|
||||
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||
const { resolveTenancy } = require('../lib/tenancy');
|
||||
|
||||
// One workspace, one device, three members: an owner, an editor and a viewer.
|
||||
const O = 'o-pip', WS = 'ws-pip', DEV = 'd-pip';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES ('u-owner','owner@t.local','x','user')").run();
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES ('u-editor','editor@t.local','x','user')").run();
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES ('u-viewer','viewer@t.local','x','user')").run();
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', 'u-owner');
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
||||
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, 'u-owner');
|
||||
db.prepare("INSERT OR IGNORE INTO workspace_members (workspace_id,user_id,role) VALUES (?,?, 'workspace_editor')").run(WS, 'u-editor');
|
||||
db.prepare("INSERT OR IGNORE INTO workspace_members (workspace_id,user_id,role) VALUES (?,?, 'workspace_viewer')").run(WS, 'u-viewer');
|
||||
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
|
||||
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Minimal socket stub — the route emits to the device room on a successful push. The deny paths
|
||||
// never reach it, but the allow paths must not 500 on a missing io.
|
||||
const emitted = [];
|
||||
const nsp = {
|
||||
adapter: { rooms: new Map([[DEV, new Set(['sock-1'])]]) },
|
||||
to: () => ({ emit: (...a) => emitted.push(a) }),
|
||||
emit: (...a) => emitted.push(a),
|
||||
};
|
||||
app.set('io', { of: () => nsp });
|
||||
app.use('/api/pip', requireAuth, resolveTenancy, require('../routes/pip'));
|
||||
const server = app.listen(0);
|
||||
|
||||
const row = (id) => db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(id);
|
||||
const tokenFor = (id) => generateToken(row(id), WS);
|
||||
|
||||
async function call(method, pathname, who, body) {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}${pathname}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(who ? { Authorization: `Bearer ${tokenFor(who)}` } : {}) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
const PUSH = { device_id: DEV, type: 'web', uri: 'https://example.com/', width: 800, height: 600, duration: 0 };
|
||||
|
||||
test('THE HOLE: a read-only member cannot push an overlay to a screen', async () => {
|
||||
assert.equal(await call('POST', '/api/pip', 'u-viewer', PUSH), 403);
|
||||
});
|
||||
|
||||
test('a read-only member cannot clear overlays either', async () => {
|
||||
// Both spellings of clear — a fix that covers one and not the other is not a fix.
|
||||
assert.equal(await call('POST', '/api/pip/clear', 'u-viewer', { device_id: DEV }), 403);
|
||||
assert.equal(await call('DELETE', '/api/pip', 'u-viewer', { device_id: DEV }), 403);
|
||||
});
|
||||
|
||||
test('an unauthenticated caller is refused', async () => {
|
||||
assert.equal(await call('POST', '/api/pip', null, PUSH), 401);
|
||||
});
|
||||
|
||||
test('AND THE OTHER HALF: an editor can still push and clear', async () => {
|
||||
// The guard must not break the feature. A workspace_editor manages content by definition.
|
||||
assert.equal(await call('POST', '/api/pip', 'u-editor', PUSH), 200);
|
||||
assert.equal(await call('POST', '/api/pip/clear', 'u-editor', { device_id: DEV }), 200);
|
||||
});
|
||||
|
||||
test('an org owner acting into the workspace can still push', async () => {
|
||||
// actingAs true, workspaceRole null — must not be mistaken for a viewer.
|
||||
assert.equal(await call('POST', '/api/pip', 'u-owner', PUSH), 200);
|
||||
});
|
||||
|
||||
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
91
server/test/schedule-calendar-expansion.test.js
Normal file
91
server/test/schedule-calendar-expansion.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use strict';
|
||||
|
||||
// The calendar is the operator's only view of what is scheduled, and it disagreed with the engine
|
||||
// in both directions for the two most-used repeat presets.
|
||||
//
|
||||
// The old expansion stepped by the recurrence unit from the schedule's original start:
|
||||
// - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
|
||||
// FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule matched only its start day. Created on a Monday it drew
|
||||
// one event a week; created on a Saturday it drew nothing at all.
|
||||
// - The walk began at the original start under a 366-iteration cap, so a schedule begun more than
|
||||
// a year ago never reached the current week and drew nothing.
|
||||
// Meanwhile the engine evaluates day-of-week directly, so those schedules ran Mon-Fri the whole
|
||||
// time. Screens were switching content that the calendar said was not scheduled.
|
||||
//
|
||||
// The invariant: the calendar draws an event on every day the schedule actually fires.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-cal-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-calendar';
|
||||
|
||||
const { expandSchedule } = require('../routes/schedules');
|
||||
|
||||
// A Monday-to-Sunday window well clear of the schedules' start dates.
|
||||
const WEEK_START = new Date('2026-08-03T00:00:00'); // Monday
|
||||
const WEEK_END = new Date('2026-08-09T23:59:59'); // Sunday
|
||||
|
||||
const mk = (recurrence, startISO, recurrenceEnd = null) => ({
|
||||
id: 's', recurrence, recurrence_end: recurrenceEnd,
|
||||
start_time: startISO,
|
||||
end_time: new Date(new Date(startISO).getTime() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
const weekdaysOf = (events) => events.map(e => new Date(e.instance_start).getDay()).sort();
|
||||
|
||||
test('THE BUG: a Mon-Fri rule draws five events, not one', () => {
|
||||
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END);
|
||||
assert.equal(ev.length, 5);
|
||||
assert.deepEqual(weekdaysOf(ev), [1, 2, 3, 4, 5], 'Mon..Fri');
|
||||
});
|
||||
|
||||
test('...and it does not matter which day the rule was created on', () => {
|
||||
// Created on a Saturday, the old code drew nothing whatsoever.
|
||||
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-08-01T09:00:00'), WEEK_START, WEEK_END);
|
||||
assert.equal(ev.length, 5);
|
||||
assert.deepEqual(weekdaysOf(ev), [1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('a DAILY schedule older than a year still draws', () => {
|
||||
// The 366-iteration cap meant the walk never reached the visible window.
|
||||
const ev = expandSchedule(mk('FREQ=DAILY', '2024-05-01T09:00:00'), WEEK_START, WEEK_END);
|
||||
assert.equal(ev.length, 7, 'every day of the week');
|
||||
});
|
||||
|
||||
test('WEEKLY without byDay still means "the same weekday as the start"', () => {
|
||||
const ev = expandSchedule(mk('FREQ=WEEKLY', '2026-07-27T09:00:00'), WEEK_START, WEEK_END); // a Monday
|
||||
assert.equal(ev.length, 1);
|
||||
assert.deepEqual(weekdaysOf(ev), [1]);
|
||||
});
|
||||
|
||||
test('a DAILY interval is honoured rather than drawn every day', () => {
|
||||
const ev = expandSchedule(mk('FREQ=DAILY;INTERVAL=2', '2026-08-03T09:00:00'), WEEK_START, WEEK_END);
|
||||
assert.equal(ev.length, 4, 'Mon, Wed, Fri, Sun');
|
||||
});
|
||||
|
||||
test('recurrence_end stops the drawing', () => {
|
||||
const ev = expandSchedule(
|
||||
mk('FREQ=DAILY', '2026-07-27T09:00:00', '2026-08-05T23:59:59'), WEEK_START, WEEK_END);
|
||||
assert.equal(ev.length, 3, 'Mon, Tue, Wed then it ends');
|
||||
});
|
||||
|
||||
test('a one-off schedule is unaffected', () => {
|
||||
const ev = expandSchedule(
|
||||
{ id: 's', recurrence: null, start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T10:00:00' },
|
||||
WEEK_START, WEEK_END);
|
||||
assert.equal(ev.length, 1);
|
||||
});
|
||||
|
||||
test('each drawn event keeps the schedule duration', () => {
|
||||
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END);
|
||||
for (const e of ev) {
|
||||
const mins = (new Date(e.instance_end) - new Date(e.instance_start)) / 60000;
|
||||
assert.equal(mins, 60);
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
102
server/test/schedule-content-item.test.js
Normal file
102
server/test/schedule-content-item.test.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
'use strict';
|
||||
|
||||
// The schedule dialog offers "Content (single item, optional)". The value was validated for
|
||||
// cross-tenancy and stored faithfully — and then read by nothing at all. services/scheduler.js acts
|
||||
// on exactly two columns:
|
||||
//
|
||||
// if (active.layout_id && ...) { ...apply... }
|
||||
// if (active.playlist_id && ...) { ...apply... }
|
||||
//
|
||||
// content_id, widget_id and zone_id are consulted nowhere. So a content-only schedule was a
|
||||
// complete no-op, while the calendar drew a block labelled with the filename as confirmation that
|
||||
// it would fire.
|
||||
//
|
||||
// Rather than thread a third override through the engine and every player, the schedule now gets a
|
||||
// playlist holding that one item — the shape the whole pipeline already understands.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-schedcontent-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-sched-content';
|
||||
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||
const { resolveTenancy } = require('../lib/tenancy');
|
||||
|
||||
const O = 'o-sc', WS = 'ws-sc', U = 'u-sc', DEV = 'dev-sc', C = 'c-sc';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sc@t.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
|
||||
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
|
||||
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
|
||||
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS);
|
||||
db.prepare("INSERT OR IGNORE INTO content (id,workspace_id,user_id,filename,filepath,mime_type,file_size) VALUES (?,?,?,'promo.jpg','promo.jpg','image/jpeg',1234)").run(C, WS, U);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.set('io', null);
|
||||
app.use('/api/schedules', requireAuth, resolveTenancy, require('../routes/schedules'));
|
||||
const server = app.listen(0);
|
||||
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
|
||||
|
||||
async function createSchedule(body) {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/schedules`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
const BASE = { device_id: DEV, title: 'Promo hour', start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T17:00:00' };
|
||||
|
||||
test('THE BUG: a content-only schedule now has something the engine can act on', async () => {
|
||||
const { status } = await createSchedule({ ...BASE, content_id: C });
|
||||
assert.equal(status, 201); // created
|
||||
const s = db.prepare('SELECT * FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
||||
assert.ok(s.playlist_id, 'the engine reads playlist_id; without one the schedule did nothing');
|
||||
});
|
||||
|
||||
test('the generated playlist contains exactly that item, published', () => {
|
||||
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
||||
const pl = db.prepare('SELECT * FROM playlists WHERE id = ?').get(s.playlist_id);
|
||||
assert.equal(pl.workspace_id, WS, 'and it belongs to the right workspace');
|
||||
assert.equal(pl.status, 'published');
|
||||
const items = db.prepare('SELECT content_id FROM playlist_items WHERE playlist_id = ?').all(s.playlist_id);
|
||||
assert.deepEqual(items.map(i => i.content_id), [C]);
|
||||
});
|
||||
|
||||
test('the snapshot the players read is populated', () => {
|
||||
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
||||
const pl = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(s.playlist_id);
|
||||
const snap = JSON.parse(pl.published_snapshot || '[]');
|
||||
assert.equal(snap.length, 1);
|
||||
assert.equal(snap[0].content_id, C);
|
||||
assert.ok(snap[0].filename, 'players need the denormalized fields, not just the id');
|
||||
});
|
||||
|
||||
test('an explicit playlist override still wins and no playlist is invented', async () => {
|
||||
db.prepare("INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES ('pl-explicit','Mine',?,?)").run(WS, U);
|
||||
const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n;
|
||||
await createSchedule({ ...BASE, content_id: C, playlist_id: 'pl-explicit' });
|
||||
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
||||
assert.equal(s.playlist_id, 'pl-explicit');
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before, 'no throwaway playlist created');
|
||||
});
|
||||
|
||||
test('a schedule with neither content nor playlist is unchanged', async () => {
|
||||
const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n;
|
||||
await createSchedule({ ...BASE, title: 'Layout only' });
|
||||
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
|
||||
assert.equal(s.playlist_id, null);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before);
|
||||
});
|
||||
|
||||
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
69
server/test/schedule-recurrence-end.test.js
Normal file
69
server/test/schedule-recurrence-end.test.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use strict';
|
||||
|
||||
// A recurring schedule ran forever. The engine compared weekday and HH:MM and dropped the date
|
||||
// component entirely, so it never read recurrence_end — a campaign set to finish on the 1st was
|
||||
// still switching screens weeks later. The calendar does read recurrence_end, so it showed the
|
||||
// campaign as stopped while the screens kept obeying it; that disagreement is what made it hard to
|
||||
// see. The end date is offered on the form, so it has to mean something.
|
||||
//
|
||||
// The same omission made a recurring schedule live BEFORE its start date, for the same reason.
|
||||
//
|
||||
// The invariant: a recurring schedule fires on and between its dates, inclusive, and never outside
|
||||
// them.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-recend-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
|
||||
const { isScheduleActiveNow } = require('../services/scheduler');
|
||||
|
||||
const TZ = 'UTC';
|
||||
// A weekday 09:00-17:00 recurring schedule that ran from the 1st to the 5th of August.
|
||||
const CAMPAIGN = {
|
||||
recurrence: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR',
|
||||
start_time: '2026-08-03T09:00:00',
|
||||
end_time: '2026-08-03T17:00:00',
|
||||
recurrence_end: '2026-08-07T23:59:59',
|
||||
};
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
test('THE BUG: it must stop after its end date', () => {
|
||||
// A Monday, inside the daily window, weeks after the campaign ended.
|
||||
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-09-07T12:00:00Z'), TZ), false,
|
||||
'a finished campaign must not still be switching screens');
|
||||
});
|
||||
|
||||
test('the final day still runs, to its normal end time', () => {
|
||||
// Inclusive end: Friday the 7th is the last day and behaves like any other.
|
||||
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-07T12:00:00Z'), TZ), true);
|
||||
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-07T18:00:00Z'), TZ), false, 'outside the daily window');
|
||||
});
|
||||
|
||||
test('it does not run before its start date either', () => {
|
||||
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-07-29T12:00:00Z'), TZ), false, 'a Wednesday, but before it begins');
|
||||
});
|
||||
|
||||
test('inside the window it behaves exactly as before', () => {
|
||||
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-05T12:00:00Z'), TZ), true, 'Wednesday, midday');
|
||||
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-05T08:00:00Z'), TZ), false, 'before the daily start');
|
||||
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-08T12:00:00Z'), TZ), false, 'Saturday is not in byDay');
|
||||
});
|
||||
|
||||
test('a recurring schedule with NO end date still runs indefinitely', () => {
|
||||
// This is the normal case and must not be broken by the fix.
|
||||
const openEnded = { ...CAMPAIGN, recurrence_end: null };
|
||||
assert.equal(isScheduleActiveNow(openEnded, at('2027-03-10T12:00:00Z'), TZ), true, 'a Wednesday, years later');
|
||||
});
|
||||
|
||||
test('a one-off schedule is unaffected', () => {
|
||||
const oneOff = { recurrence: null, start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T17:00:00' };
|
||||
assert.equal(isScheduleActiveNow(oneOff, at('2026-08-05T12:00:00Z'), TZ), true);
|
||||
assert.equal(isScheduleActiveNow(oneOff, at('2026-08-06T12:00:00Z'), TZ), false);
|
||||
});
|
||||
|
||||
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
102
server/test/text-widget-font-size.test.js
Normal file
102
server/test/text-widget-font-size.test.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
'use strict';
|
||||
|
||||
// Hand-written HTML in the Text/HTML widget was rendered far too small to read.
|
||||
//
|
||||
// The Content Designer used to publish absolute sizes as fontSize*10.8 px, and renderText converted
|
||||
// px/108 back to vw to restore the intended size and let those widgets scale. That rescue is
|
||||
// correct — but it ran over EVERY text widget, including markup a person typed themselves. So
|
||||
// `font-size:16px` became 0.15vw: 2.8px on a 1080p screen, 1.9px on a 1280 one. Not clipped, not
|
||||
// hidden — rendered at a size nobody can read, in the one widget whose entire purpose is
|
||||
// hand-written HTML.
|
||||
//
|
||||
// Today's designer emits cqw, not px (frontend/js/views/designer.js), so the conversion only ever
|
||||
// needed to apply to legacy designer output. That is identified by absolutely-positioned elements,
|
||||
// the same signal the dashboard uses to decide whether a text widget can be reopened in the
|
||||
// designer.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-textwidget-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.JWT_SECRET = 'test-secret-text-widget';
|
||||
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||
|
||||
function seed() {
|
||||
const u = 'u-tw', o = 'o-tw', ws = 'ws-tw';
|
||||
db.prepare("INSERT OR IGNORE INTO users (id, email, password_hash, role) VALUES (?,?, 'x','user')").run(u, 'tw@test.local');
|
||||
db.prepare('INSERT OR IGNORE INTO organizations (id, name, owner_user_id) VALUES (?,?,?)').run(o, 'org', u);
|
||||
db.prepare('INSERT OR IGNORE INTO workspaces (id, organization_id, name) VALUES (?,?,?)').run(ws, o, 'ws');
|
||||
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id, user_id, role) VALUES (?,?, 'org_owner')").run(o, u);
|
||||
return { u, ws };
|
||||
}
|
||||
const { u, ws } = seed();
|
||||
|
||||
function makeWidget(id, html) {
|
||||
db.prepare(`INSERT OR REPLACE INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'text', ?, ?, strftime('%s','now'), strftime('%s','now'))`)
|
||||
.run(id, u, ws, id, JSON.stringify({ html, background: '#000' }));
|
||||
return id;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/widgets', requireAuth, require('../routes/widgets'));
|
||||
const server = app.listen(0);
|
||||
const token = generateToken(db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(u), ws);
|
||||
|
||||
async function render(id) {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/widgets/${id}/render`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.text();
|
||||
}
|
||||
|
||||
test('THE BUG: hand-written px font sizes must survive untouched', async () => {
|
||||
const id = makeWidget('w-hand', '<h1 style="color:#fff;font-size:40px">Notice</h1>');
|
||||
const out = await render(id);
|
||||
assert.match(out, /font-size:40px/, 'a hand-typed 40px must render as 40px');
|
||||
assert.doesNotMatch(out, /font-size:0\.37vw/, '40px/108 = 0.37vw is ~7px on 1080p — unreadable');
|
||||
});
|
||||
|
||||
test('a small hand-written size is not shrunk into invisibility', async () => {
|
||||
const id = makeWidget('w-hand-small', '<p style="color:#fff;font-size:16px">Body copy</p>');
|
||||
const out = await render(id);
|
||||
assert.match(out, /font-size:16px/);
|
||||
assert.doesNotMatch(out, /font-size:0\.15vw/, '0.15vw is 2.8px on a 1080p screen');
|
||||
});
|
||||
|
||||
test('LEGACY designer output is still rescued, so old widgets keep scaling', async () => {
|
||||
// Absolutely-positioned elements are the designer's signature. 54px was fontSize 5 * 10.8.
|
||||
const id = makeWidget('w-designer',
|
||||
'<div style="position:absolute;left:10%;top:20%;font-size:54px;color:#fff">Designed</div>');
|
||||
const out = await render(id);
|
||||
assert.match(out, /font-size:0\.50vw/, 'legacy designer px must still convert back to vw');
|
||||
assert.doesNotMatch(out, /font-size:54px/);
|
||||
});
|
||||
|
||||
test('a designer widget with several sizes converts all of them', async () => {
|
||||
const id = makeWidget('w-designer-multi',
|
||||
'<div style="position:absolute;left:0;font-size:108px">A</div>' +
|
||||
'<div style="position:absolute;left:50%;font-size:21.6px">B</div>');
|
||||
const out = await render(id);
|
||||
assert.match(out, /font-size:1\.00vw/);
|
||||
assert.match(out, /font-size:0\.20vw/);
|
||||
});
|
||||
|
||||
test('hand-written markup that merely mentions absolute positioning elsewhere is not misread', async () => {
|
||||
// The signal is `position:absolute` immediately followed by `left:` — the designer's own shape.
|
||||
// A hand-written absolute element without that pairing keeps its px.
|
||||
const id = makeWidget('w-hand-abs', '<div style="position:absolute;top:10px;font-size:32px">X</div>');
|
||||
const out = await render(id);
|
||||
assert.match(out, /font-size:32px/, 'only the designer\'s left-first shape triggers the rescue');
|
||||
});
|
||||
|
||||
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
|
|
@ -186,6 +186,23 @@ function resolveGroupSync(device, deviceId) {
|
|||
return { group_id: group.id, is_leader: leaderId === deviceId };
|
||||
}
|
||||
|
||||
// A widget's CONTENT is always live — /api/widgets/:id/render reads the current config — but the
|
||||
// playlist payload is a snapshot taken at publish time, so a widget edited afterwards still carried
|
||||
// its published revision. The player keeps a widget's WebView while its URL is unchanged (re-
|
||||
// navigating a widget every duration is a visible flash and destroys widget state), so an unchanged
|
||||
// URL meant an edit only reached the screen after an app restart.
|
||||
//
|
||||
// Refreshing the rev here, at send time, makes the URL differ exactly when the content differs —
|
||||
// and only then, so the anti-flash reuse still holds for widgets nobody has touched.
|
||||
const widgetRevOf = db.prepare('SELECT updated_at FROM widgets WHERE id = ?').pluck();
|
||||
function refreshWidgetRevs(assignments) {
|
||||
if (!Array.isArray(assignments)) return;
|
||||
for (const a of assignments) {
|
||||
if (!a || !a.widget_id) continue;
|
||||
try { a.widget_rev = widgetRevOf.get(a.widget_id) ?? a.widget_rev ?? 0; } catch (_) { /* keep published */ }
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlaylistPayload(deviceId) {
|
||||
const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId);
|
||||
|
||||
|
|
@ -194,6 +211,7 @@ function buildPlaylistPayload(deviceId) {
|
|||
const playlist = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
|
||||
if (playlist?.published_snapshot) {
|
||||
try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; }
|
||||
refreshWidgetRevs(assignments);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -764,7 +782,18 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// null-token device" path is removed — that was the re-provisioning vector.
|
||||
const tokenToSend = device.device_token;
|
||||
|
||||
if (device_info) applyDeviceInfo(device_id, device_info);
|
||||
// An EMPTY device_info means "I have nothing new to tell you", not "wipe what you know".
|
||||
// The web/BrightSign player's refresh-register sends `device_info: {}` on a 300s timer,
|
||||
// and `{}` is truthy — so every five minutes applyDeviceInfo, which is a blind full-row
|
||||
// overwrite with no per-field presence check, bound `undefined` for all 17 columns.
|
||||
// better-sqlite3 stores those as NULL rather than throwing, so the write succeeded:
|
||||
// android_version, app_version, screen_width/height, render_*, ota_*, tier, the four
|
||||
// capability flags and the four volume/brightness columns were all nulled. Fleet view,
|
||||
// resolution diagnostics and version-based logic read blank for exactly the client family
|
||||
// that cannot be inspected any other way. The code around this already anticipates the
|
||||
// shape — recordReconnect/persistIdentity are gated behind `if (!isPlaylistRefresh)` —
|
||||
// this call was the one that was not.
|
||||
if (device_info && Object.keys(device_info).length > 0) applyDeviceInfo(device_id, device_info);
|
||||
|
||||
heartbeat.registerConnection(device_id, socket.id);
|
||||
// #134: a same-socket re-register is a playlist REFRESH (~45-60s), NOT a reconnect and NOT
|
||||
|
|
@ -1079,9 +1108,14 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// Playback state update
|
||||
socket.on('device:playback-state', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
// currentDeviceId is the authenticated device for this socket; use it
|
||||
// for the workspace lookup since data may not carry device_id consistently.
|
||||
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state', data);
|
||||
// currentDeviceId is the authenticated device for this socket; use it for the workspace
|
||||
// lookup since data may not carry device_id consistently — and STAMP it over whatever the
|
||||
// payload claims before relaying. This was the only relay forwarding the client's object
|
||||
// verbatim, so a device could report progress attributed to a different screen in the same
|
||||
// workspace and the dashboard would believe it. Every other relay here stamps the
|
||||
// authenticated id; this one now matches.
|
||||
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state',
|
||||
{ ...(data || {}), device_id: currentDeviceId });
|
||||
});
|
||||
|
||||
// Live debug log line from the player (only sent when debug logging is toggled
|
||||
|
|
|
|||
|
|
@ -148,7 +148,10 @@ PlaylistPlayer.prototype.load = function (assignments) {
|
|||
// transition-engine: include the per-item transition, or a transition change keeps the same
|
||||
// signature -> "unchanged" -> the player never applies the new transitions.
|
||||
// duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart.
|
||||
return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
|
||||
// widget_rev for the same reason as schedules and transition above: a widget's IDENTITY
|
||||
// is unchanged when it is EDITED, so a content edit produced an identical signature, the
|
||||
// update was treated as unchanged, and the screen kept the old render until a restart.
|
||||
return [a.content_id, a.widget_id, a.widget_rev || 0, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
|
||||
}));
|
||||
if (sig === this.sig && this.items.length) {
|
||||
// In-place duration refresh: patch duration_sec on the live items so a duration edit takes effect
|
||||
|
|
@ -176,10 +179,11 @@ PlaylistPlayer.prototype.load = function (assignments) {
|
|||
if (!items.length) { this.index = 0; this.startPlayback(); return; }
|
||||
|
||||
// Current item survives -> keep playing it, just retarget the index (no restart).
|
||||
if (curId) {
|
||||
if (curId && !this._forceRender) {
|
||||
var stay = this.indexOfIdentity(items, curId);
|
||||
if (stay >= 0 && this.hasContentOnScreen()) { this.index = stay; return; }
|
||||
}
|
||||
this._forceRender = false;
|
||||
|
||||
// Anchor gone: walk forward from the OLD position to the first item that still exists.
|
||||
var nextIdx = 0;
|
||||
|
|
@ -195,9 +199,28 @@ PlaylistPlayer.prototype.load = function (assignments) {
|
|||
// #157: removed-but-live in solo playback -> don't interrupt; rotate out on the next advance
|
||||
// (the current item's video onended / image timer still fires advance()). Group-sync (schedule-
|
||||
// driven) and wall followers reconcile via their own tick, so play through immediately as before.
|
||||
if (this.hasContentOnScreen() && !this.wallFollower && !this.scheduleDriven) {
|
||||
// ...but only when an advance is actually coming. Single-item playback here deliberately has
|
||||
// none: `single` makes renderImage, renderVideo and renderWidget all skip their timer (a solo
|
||||
// item is meant to sit there), so replacing the one item of a one-item playlist deferred forever
|
||||
// and the old content stayed on the screen. On Tizen this strands IMAGES too, not just video and
|
||||
// widgets as on the web player, because the timer is skipped for every type.
|
||||
var outgoingNeverAdvances = !this.items || this.items.length <= 1;
|
||||
if (this.hasContentOnScreen() && !this.wallFollower && !this.scheduleDriven && !outgoingNeverAdvances) {
|
||||
this._deferredRotation = true;
|
||||
this._deferredSuccessorId = this.itemIdentity(items[nextIdx]);
|
||||
// Safety net: a deferral is a bet that an advance will arrive. If it does not, apply the
|
||||
// change anyway rather than leave the screen on content the operator has replaced.
|
||||
var self = this;
|
||||
if (this._deferredDeadline) clearTimeout(this._deferredDeadline);
|
||||
this._deferredDeadline = setTimeout(function () {
|
||||
if (!self._deferredRotation) return;
|
||||
self._deferredRotation = false;
|
||||
var di = -1;
|
||||
for (var k = 0; k < self.items.length; k++) {
|
||||
if (self.itemIdentity(self.items[k]) === self._deferredSuccessorId) { di = k; break; }
|
||||
}
|
||||
self.startPlaybackAt(di === -1 ? 0 : di);
|
||||
}, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +270,7 @@ PlaylistPlayer.prototype.advance = function () {
|
|||
// stashed list and continue at the preserved successor instead of interrupting/restarting.
|
||||
if (this._deferredRotation) {
|
||||
this._deferredRotation = false;
|
||||
if (this._deferredDeadline) { clearTimeout(this._deferredDeadline); this._deferredDeadline = null; }
|
||||
var sid = this._deferredSuccessorId; this._deferredSuccessorId = null;
|
||||
var to = sid ? this.indexOfIdentity(this.items, sid) : -1;
|
||||
this.startPlaybackAt(to >= 0 ? to : 0);
|
||||
|
|
@ -284,7 +308,16 @@ PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || nul
|
|||
// leaving wall mode (or a role flip) calls invalidate() so the next load re-renders
|
||||
// with the right semantics instead of being de-duped by the unchanged signature.
|
||||
PlaylistPlayer.prototype.setWallFollower = function (b) { this.wallFollower = !!b; };
|
||||
PlaylistPlayer.prototype.invalidate = function () { this.sig = ''; };
|
||||
PlaylistPlayer.prototype.invalidate = function () {
|
||||
this.sig = '';
|
||||
// Clearing the signature alone was not enough: load() returns at the continuity check ("current
|
||||
// item survives -> keep playing it, just retarget the index") BEFORE reaching any render, so the
|
||||
// invalidate had no effect and the item kept the semantics of the mode we had just left. Leaving
|
||||
// a sync group or a wall therefore froze the screen on one clip — rendered with `single`, so
|
||||
// looping with no timer — and every later refresh took the unchanged path because the element
|
||||
// was attached and playing, i.e. healthy. This flag makes the next load actually re-render.
|
||||
this._forceRender = true;
|
||||
};
|
||||
PlaylistPlayer.prototype.getIndex = function () { return this.index; };
|
||||
PlaylistPlayer.prototype.getItemCount = function () { return this.items.length; };
|
||||
PlaylistPlayer.prototype.isWallFollower = function () { return !!this.wallFollower; };
|
||||
|
|
@ -821,7 +854,7 @@ PlaylistPlayer.prototype.renderYouTube = function (item, single) {
|
|||
|
||||
PlaylistPlayer.prototype.renderWidget = function (item, single) {
|
||||
var self = this;
|
||||
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '');
|
||||
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (item.widget_rev || 0);
|
||||
// Anti-flash (#directory-board, parity with the web player): build the new iframe hidden ON TOP of the
|
||||
// current content and reveal it on load, THEN drop everything else — so a widget/directory-board
|
||||
// reload never black-flashes the stage (playCurrent skipped the pre-clear for widgets).
|
||||
|
|
@ -1037,7 +1070,7 @@ ZoneRenderer.prototype.showItem = function (zone, list, index) {
|
|||
zone.el.appendChild(zrFrame(ysrc, 'autoplay; encrypted-media', yvert));
|
||||
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||||
} else if (a.widget_type || (a.widget_id && !a.content_id)) {
|
||||
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '')));
|
||||
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (a.widget_rev || 0)));
|
||||
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||||
} else if (mime.indexOf('video/') === 0) {
|
||||
var v = document.createElement('video');
|
||||
|
|
@ -1290,7 +1323,15 @@ GroupSyncController.prototype.target = function () {
|
|||
};
|
||||
GroupSyncController.prototype.tick = function () {
|
||||
if (!this.groupId || !this.player.items.length) return;
|
||||
var t = this.target(); if (!t) return;
|
||||
// No target means every item is currently outside its daypart. Returning here left the whole
|
||||
// group displaying (or looping) whatever was in-window last, out of hours — while an identical
|
||||
// ungrouped screen correctly showed the idle card. Group members are schedule-driven, so no
|
||||
// renderer arms a timer and nothing else was watching for this.
|
||||
var t = this.target();
|
||||
if (!t) {
|
||||
if (this.player.hasContentOnScreen()) this.player.nothingScheduled();
|
||||
return;
|
||||
}
|
||||
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
|
||||
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6) {
|
||||
this.player.preloadVideo(t.nextIndex); // warm next clip (video-only; no-ops otherwise)
|
||||
|
|
|
|||
Loading…
Reference in a new issue