mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Compare commits
27 commits
v1.9.1-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9fb914b9e | ||
|
|
ce78d0dde4 | ||
|
|
f206537fed | ||
|
|
139d7d09fa | ||
|
|
852219cb45 | ||
|
|
15448d1c5d | ||
|
|
29a8896aa8 | ||
|
|
101f086204 | ||
|
|
ed3cf72b82 | ||
|
|
d90cfb3986 | ||
|
|
f96b65576f | ||
|
|
ed164647b8 | ||
|
|
ae018b8eea | ||
|
|
071d7cc9c3 | ||
|
|
1e1ed7e29a | ||
|
|
36c4bf523f | ||
|
|
16c381254b | ||
|
|
01e5b10f53 | ||
|
|
9c990ff91f | ||
|
|
a6fe849c67 | ||
|
|
0c0a8dd68a | ||
|
|
aa23cf02dd | ||
|
|
a9cf8747cb | ||
|
|
99cad902f2 | ||
|
|
0ebbd20968 | ||
|
|
184f07c272 | ||
|
|
a36880b147 |
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -1,5 +1,36 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.9.2-beta1 — unreleased
|
||||||
|
|
||||||
|
### Fixed — server resilience (#142)
|
||||||
|
- **A single flapping device can no longer saturate the event loop.** A new
|
||||||
|
load-aware, per-device reconnect throttle (`lib/reconnect-throttle.js`) gates
|
||||||
|
genuine reconnects *before* the heavy register work (DB writes + playlist build).
|
||||||
|
The verdict is per-device; global event-loop lag only multiplies an
|
||||||
|
already-flagged device's backoff and never throttles a healthy one. Hard ceiling
|
||||||
|
+ cold-start warm-up so a full-fleet reconnect after a deploy is never throttled.
|
||||||
|
- **`device_status_log` growth is bounded.** Added
|
||||||
|
`idx_device_status_log_device_ts`, a global retention sweep (`pruneStatusLog`,
|
||||||
|
`STATUS_LOG_RETENTION_DAYS` default 3) covering removed/idle devices and the
|
||||||
|
`offline_timeout` path, and de-duplicated the table's `CREATE TABLE`.
|
||||||
|
- **`content-ack` spam de-duplicated.** Repeated identical
|
||||||
|
`(device_id, content_id, status)` reports are suppressed within
|
||||||
|
`CONTENT_ACK_DEDUP_MS` (default 10s).
|
||||||
|
- **Provisioning cleanup window corrected.** Unclaimed provisioning devices are now
|
||||||
|
swept after 24h (the code used `365 * 86400` — a year — contradicting its own
|
||||||
|
comment).
|
||||||
|
|
||||||
|
### Added — observability (#142)
|
||||||
|
- **Event-loop lag telemetry** via `perf_hooks.monitorEventLoopDelay()`. Sampled to
|
||||||
|
a bounded `event_loop_lag` table (indexed + pruned, `LAG_TELEMETRY_RETENTION_DAYS`)
|
||||||
|
and surfaced on `/api/status` as `loop_lag` (mean/p50/p99/max + band).
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
- Operators whose `device_status_log` is already bloated from a pre-1.9.2 deployment
|
||||||
|
should reclaim disk with a **one-time manual `VACUUM`** in a maintenance window;
|
||||||
|
retention now bounds further growth. Auto-VACUUM is intentionally not enabled.
|
||||||
|
See [`docs/maintenance-device-status-log.md`](docs/maintenance-device-status-log.md).
|
||||||
|
|
||||||
## 1.9.1-beta3 — unreleased
|
## 1.9.1-beta3 — unreleased
|
||||||
|
|
||||||
### Fixed — Tizen player
|
### Fixed — Tizen player
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,7 @@ keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -
|
||||||
3. Install the ScreenTinker app on your device:
|
3. Install the ScreenTinker app on your device:
|
||||||
- **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above)
|
- **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above)
|
||||||
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash`
|
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash`
|
||||||
|
- **Debian 13 (headless)**: `curl -sSL https://your-instance/scripts/debian-13-setup.sh | sudo bash`
|
||||||
- **Windows**: Run the setup script from `scripts/windows-setup.bat`
|
- **Windows**: Run the setup script from `scripts/windows-setup.bat`
|
||||||
- **Samsung Tizen TV / signage**: point the TV's URL Launcher (or browser) at `https://your-instance/player` - no signing needed. For an installed native app, see [tizen/README.md](tizen/README.md)
|
- **Samsung Tizen TV / signage**: point the TV's URL Launcher (or browser) at `https://your-instance/player` - no signing needed. For an installed native app, see [tizen/README.md](tizen/README.md)
|
||||||
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
|
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
|
||||||
|
|
|
||||||
25
SECURITY.md
25
SECURITY.md
|
|
@ -95,3 +95,28 @@ by name in release notes and (when applicable) in the GitHub advisory
|
||||||
itself. Let me know in your report whether you'd like credit and how
|
itself. Let me know in your report whether you'd like credit and how
|
||||||
you'd like to be named. Anonymous reports are also welcome — no credit
|
you'd like to be named. Anonymous reports are also welcome — no credit
|
||||||
is required.
|
is required.
|
||||||
|
|
||||||
|
## Uploaded content access model
|
||||||
|
|
||||||
|
Uploaded content (images, videos) served under /uploads/content is
|
||||||
|
**public by unguessable URL**, not access-controlled:
|
||||||
|
|
||||||
|
- Filenames are UUIDv4 (122 bits of randomness), so URLs are not enumerable
|
||||||
|
or guessable.
|
||||||
|
- There is no per-request authentication on content bytes, and CORS is open
|
||||||
|
(Access-Control-Allow-Origin: *) because the web player's canvas-based
|
||||||
|
screenshot capture requires cross-origin access.
|
||||||
|
- Anyone who obtains a content URL can read that file, cross-tenant, with no
|
||||||
|
expiry (immutable 30-day cache) and no revocation short of deleting the file.
|
||||||
|
|
||||||
|
This is an intentional design choice for digital signage, where content is
|
||||||
|
destined for public display. It is **security-through-unguessability, not
|
||||||
|
access control.**
|
||||||
|
|
||||||
|
**Do not upload content you require to remain confidential** - including
|
||||||
|
material that is destined for a screen but not yet public (e.g. a scheduled
|
||||||
|
promotion before its reveal, or an internal board containing names or other
|
||||||
|
sensitive details). Such content is world-readable from the moment of upload.
|
||||||
|
If pre-launch or tenant-private confidentiality is a requirement for your
|
||||||
|
deployment, open an issue - signed/expiring URLs are tracked but not yet
|
||||||
|
implemented.
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.remotedisplay.player"
|
applicationId = "com.remotedisplay.player"
|
||||||
minSdk = 26
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 25
|
versionCode = 31
|
||||||
versionName = "1.9.1-beta5"
|
versionName = "1.9.2-beta1"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// Start auto-update checker
|
// Start auto-update checker
|
||||||
updateChecker = UpdateChecker(this)
|
updateChecker = UpdateChecker(this)
|
||||||
|
// #139: surface OTA status (applying / backing off / manual-update-required) to the
|
||||||
|
// dashboard. wsService is read lazily — it binds after this runs.
|
||||||
|
updateChecker.otaLogReporter = { level, msg -> wsService?.sendLog("ota", level, msg) }
|
||||||
|
// #139 Phase 2 (Option B): announce OTA status transitions (clear / enter-backoff) so the
|
||||||
|
// dashboard badge clears/lights up promptly without waiting for a reconnect.
|
||||||
|
updateChecker.otaStatusReporter = { wsService?.sendOtaStatus() }
|
||||||
updateChecker.startPeriodicCheck()
|
updateChecker.startPeriodicCheck()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -657,7 +663,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
// YouTube content - play in WebView
|
// YouTube content - play in WebView
|
||||||
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
|
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
|
||||||
Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}")
|
Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}")
|
||||||
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec)
|
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec, item.muted)
|
||||||
wsService?.sendPlaybackState(item.contentId, 0f)
|
wsService?.sendPlaybackState(item.contentId, 0f)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,37 @@ class ServerConfig(context: Context) {
|
||||||
fun clearPlaylistCache() {
|
fun clearPlaylistCache() {
|
||||||
prefs.edit().remove("cached_playlist").apply()
|
prefs.edit().remove("cached_playlist").apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #139 OTA attempt state. Persisted (not in-memory) on purpose: the OTA loop is driven
|
||||||
|
// by Fire OS restarting the app, which re-fires the update check; an in-memory counter
|
||||||
|
// would reset on every restart and never back off. `otaTargetVersion` is the version we
|
||||||
|
// are currently trying to install; `otaAttempts` counts install attempts for it;
|
||||||
|
// `otaLastAttemptAt` gates the post-cap retry backoff.
|
||||||
|
var otaTargetVersion: String
|
||||||
|
get() = prefs.getString("ota_target_version", "") ?: ""
|
||||||
|
set(value) = prefs.edit().putString("ota_target_version", value).apply()
|
||||||
|
|
||||||
|
var otaAttempts: Int
|
||||||
|
get() = prefs.getInt("ota_attempts", 0)
|
||||||
|
set(value) = prefs.edit().putInt("ota_attempts", value).apply()
|
||||||
|
|
||||||
|
var otaLastAttemptAt: Long
|
||||||
|
get() = prefs.getLong("ota_last_attempt_at", 0L)
|
||||||
|
set(value) = prefs.edit().putLong("ota_last_attempt_at", value).apply()
|
||||||
|
|
||||||
|
// #139: true once the "entering backoff" status has been reported for the current target,
|
||||||
|
// so the dashboard line fires on the transition only — not on every backed-off poll (Fire OS
|
||||||
|
// restarts re-fire the check constantly). Reset on a new target / on clear.
|
||||||
|
var otaBackoffReported: Boolean
|
||||||
|
get() = prefs.getBoolean("ota_backoff_reported", false)
|
||||||
|
set(value) = prefs.edit().putBoolean("ota_backoff_reported", value).apply()
|
||||||
|
|
||||||
|
fun clearOtaState() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("ota_target_version")
|
||||||
|
.remove("ota_attempts")
|
||||||
|
.remove("ota_last_attempt_at")
|
||||||
|
.remove("ota_backoff_reported")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,14 @@ class MediaPlayerManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playYoutube(embedUrl: String, durationSec: Int = 0) {
|
// #129: remembered so the live device:mute-changed toggle knows YouTube's current
|
||||||
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl")
|
// state and the IFrame API bridge can flip it without reloading the embed.
|
||||||
|
private var youtubeMuted = false
|
||||||
|
|
||||||
|
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
|
||||||
|
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
|
||||||
currentType = MediaType.YOUTUBE
|
currentType = MediaType.YOUTUBE
|
||||||
|
youtubeMuted = muted || wallMute
|
||||||
|
|
||||||
playerView.visibility = android.view.View.GONE
|
playerView.visibility = android.view.View.GONE
|
||||||
imageView.visibility = android.view.View.GONE
|
imageView.visibility = android.view.View.GONE
|
||||||
|
|
@ -64,12 +69,25 @@ class MediaPlayerManager(
|
||||||
com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube")
|
com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube")
|
||||||
setBackgroundColor(android.graphics.Color.BLACK)
|
setBackgroundColor(android.graphics.Color.BLACK)
|
||||||
// Load via an embed wrapper with a valid youtube.com origin (Error 153 fix).
|
// Load via an embed wrapper with a valid youtube.com origin (Error 153 fix).
|
||||||
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl)
|
// #129: initial mute comes from the per-item flag (no longer hardcoded).
|
||||||
|
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl, youtubeMuted)
|
||||||
if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
||||||
else loadUrl(embedUrl)
|
else loadUrl(embedUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #129: live mute for the YouTube embed via the IFrame API postMessage bridge
|
||||||
|
// (enablejsapi=1 is set on the embed). Avoids a full reload of the player, which
|
||||||
|
// 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"
|
||||||
|
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) {} } }
|
||||||
|
}
|
||||||
|
|
||||||
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
|
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
|
||||||
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
|
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
|
||||||
fun showWidget(url: String) {
|
fun showWidget(url: String) {
|
||||||
|
|
@ -185,12 +203,17 @@ class MediaPlayerManager(
|
||||||
fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true)
|
fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true)
|
||||||
|
|
||||||
// #129: live per-item mute. Applies a dashboard mute toggle to the CURRENTLY playing
|
// #129: live per-item mute. Applies a dashboard mute toggle to the CURRENTLY playing
|
||||||
// video in real time (decoupled from a playlist reload). Only video carries audio here
|
// item in real time (decoupled from a playlist reload). Native video -> ExoPlayer
|
||||||
// — YouTube embeds autoplay muted and images/widgets are silent — so this targets the
|
// volume; YouTube -> the IFrame API mute()/unMute() bridge (setYoutubeMuted), which
|
||||||
// ExoPlayer volume. Persistence across the next play comes from the playlist payload's
|
// previously this method ignored so YouTube could never be un/muted live. Images/
|
||||||
// per-item `muted` (honored in playVideo). Main thread only.
|
// widgets are silent. Persistence across the next play comes from the playlist
|
||||||
|
// payload's per-item `muted` (honored in playVideo/playYoutube). Main thread only.
|
||||||
fun setVideoMuted(muted: Boolean) {
|
fun setVideoMuted(muted: Boolean) {
|
||||||
if (currentType == MediaType.VIDEO) exoPlayer?.volume = if (muted) 0f else 1f
|
when (currentType) {
|
||||||
|
MediaType.VIDEO -> exoPlayer?.volume = if (muted) 0f else 1f
|
||||||
|
MediaType.YOUTUBE -> setYoutubeMuted(muted) // #129: was a no-op for YouTube
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Video-wall (wall:sync) accessors. All must be called on the main thread. ----
|
// ---- Video-wall (wall:sync) accessors. All must be called on the main thread. ----
|
||||||
|
|
|
||||||
|
|
@ -97,10 +97,24 @@ class ZoneManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group assignments by zone_id, ordered by sort_order so rotation is stable.
|
// Group assignments by zone_id, ordered by sort_order so rotation is stable.
|
||||||
|
// Zone-orphan fallback: an item whose zone_id isn't a zone in the ACTIVE layout
|
||||||
|
// (assigned under a different layout, or the layout was duplicated/switched — copies
|
||||||
|
// get fresh zone ids) would otherwise be SILENTLY DROPPED: its bucket matches no
|
||||||
|
// rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it shares
|
||||||
|
// screen time there (one item at a time -> never overlays existing content) and warn
|
||||||
|
// via DebugLog (mirrored to the dashboard device-log when debug is on).
|
||||||
|
val validZoneIds = zones.map { it.id }.toHashSet()
|
||||||
|
val fallbackZone = zones.maxByOrNull { it.widthPercent * it.heightPercent }
|
||||||
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
|
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
|
||||||
for (i in 0 until assignments.length()) {
|
for (i in 0 until assignments.length()) {
|
||||||
val a = assignments.getJSONObject(i)
|
val a = assignments.getJSONObject(i)
|
||||||
val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
|
var zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
|
||||||
|
if (zoneId != null && !validZoneIds.contains(zoneId) && fallbackZone != null) {
|
||||||
|
val item = a.optString("filename", "").ifEmpty { a.optString("content_id", a.optString("widget_id", "?")) }
|
||||||
|
com.remotedisplay.player.util.DebugLog.w("Zone",
|
||||||
|
"orphan zone_id=$zoneId item=$item -> fallback zone '${fallbackZone.name}'")
|
||||||
|
zoneId = fallbackZone.id
|
||||||
|
}
|
||||||
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
|
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
|
||||||
}
|
}
|
||||||
assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } }
|
assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } }
|
||||||
|
|
@ -199,7 +213,9 @@ class ZoneManager(
|
||||||
// YouTube - render via an embed wrapper with a valid origin (Error 153 fix)
|
// YouTube - render via an embed wrapper with a valid origin (Error 153 fix)
|
||||||
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
|
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
|
||||||
val webView = createWebView()
|
val webView = createWebView()
|
||||||
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl)
|
// #129: initial mute from the per-item flag (was hardcoded mute=1). Live
|
||||||
|
// per-zone mute isn't wired (device:mute-changed targets the fullscreen item).
|
||||||
|
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl, isMuted)
|
||||||
if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
||||||
else webView.loadUrl(remoteUrl)
|
else webView.loadUrl(remoteUrl)
|
||||||
webView.layoutParams = params
|
webView.layoutParams = params
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package com.remotedisplay.player.service
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #139: pure OTA throttle decision logic — no Android dependencies, so it's unit-testable
|
||||||
|
* (see OtaThrottleTest). UpdateChecker is the imperative shell: it reads/writes the persisted
|
||||||
|
* fields (ServerConfig / EncryptedSharedPreferences) and performs the actual download + install;
|
||||||
|
* this object owns the stateful RULES so they have coverage beyond a compile:
|
||||||
|
*
|
||||||
|
* - a new target version resets the attempt budget,
|
||||||
|
* - a check NEVER consumes the budget — only a launched install does (so a transient
|
||||||
|
* download/network failure can't park a healthy device in backoff),
|
||||||
|
* - after MAX_INSTALL_ATTEMPTS failed installs, back off to one retry per BACKOFF_MS,
|
||||||
|
* - the "entering backoff" signal fires on the crossing only (report-on-transition).
|
||||||
|
*/
|
||||||
|
object OtaThrottle {
|
||||||
|
const val MAX_INSTALL_ATTEMPTS = 3
|
||||||
|
const val BACKOFF_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Persisted OTA state for the version we are currently trying to install. */
|
||||||
|
data class State(
|
||||||
|
val targetVersion: String = "",
|
||||||
|
val attempts: Int = 0,
|
||||||
|
val lastAttemptAt: Long = 0L,
|
||||||
|
val backoffReported: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Action { ATTEMPT, BACKOFF }
|
||||||
|
|
||||||
|
/** True when [latestVersion] differs from the persisted target — caller drops stale APKs. */
|
||||||
|
fun isNewTarget(state: State, latestVersion: String): Boolean = state.targetVersion != latestVersion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A check found [latestVersion] available. Returns the state to persist (reset on a new
|
||||||
|
* target) and whether to attempt now. Does NOT count an attempt: the budget is consumed
|
||||||
|
* only once an install is actually launched (see [onInstallLaunched]).
|
||||||
|
*/
|
||||||
|
fun onUpdateAvailable(state: State, latestVersion: String, now: Long): Pair<State, Action> {
|
||||||
|
val s = if (isNewTarget(state, latestVersion)) State(targetVersion = latestVersion) else state
|
||||||
|
if (s.attempts >= MAX_INSTALL_ATTEMPTS && now - s.lastAttemptAt < BACKOFF_MS) {
|
||||||
|
return s to Action.BACKOFF
|
||||||
|
}
|
||||||
|
return s to Action.ATTEMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An install was actually launched (a verified APK was in hand). Consumes one attempt and
|
||||||
|
* returns the new state plus whether this attempt is the FIRST to cross the cap into backoff
|
||||||
|
* (true => caller reports "manual update required" once; false on all later polls).
|
||||||
|
*/
|
||||||
|
fun onInstallLaunched(state: State, now: Long): Pair<State, Boolean> {
|
||||||
|
val attempts = state.attempts + 1
|
||||||
|
var s = state.copy(attempts = attempts, lastAttemptAt = now)
|
||||||
|
val enteredBackoff = attempts >= MAX_INSTALL_ATTEMPTS && !s.backoffReported
|
||||||
|
if (enteredBackoff) s = s.copy(backoffReported = true)
|
||||||
|
return s to enteredBackoff
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A check found us already on the latest. True if there was pending OTA state to clear. */
|
||||||
|
fun shouldClearOnUpToDate(state: State): Boolean = state.targetVersion.isNotEmpty()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #139 Phase 2: operator-facing status for the dashboard.
|
||||||
|
* - "none" : no update pending.
|
||||||
|
* - "manual_update_required" : capped AND still inside the backoff window — this device
|
||||||
|
* can't self-install; a human needs to update it.
|
||||||
|
* - "pending" : an update is in progress / will retry (under the cap, or the
|
||||||
|
* window has elapsed so a retry is due).
|
||||||
|
*/
|
||||||
|
fun statusFor(state: State, now: Long): String = when {
|
||||||
|
state.targetVersion.isEmpty() -> "none"
|
||||||
|
state.attempts >= MAX_INSTALL_ATTEMPTS && now - state.lastAttemptAt < BACKOFF_MS -> "manual_update_required"
|
||||||
|
else -> "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,25 @@ class UpdateChecker(private val context: Context) {
|
||||||
|
|
||||||
private var installReceiverRegistered = false
|
private var installReceiverRegistered = false
|
||||||
|
|
||||||
|
// #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.
|
||||||
|
// The throttle thresholds + decision rules live in OtaThrottle (pure, unit-tested); this
|
||||||
|
// class is the imperative shell that persists state and does the download/install.
|
||||||
|
var otaLogReporter: ((level: String, message: String) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun report(level: String, message: String) {
|
||||||
|
when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) }
|
||||||
|
try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #139 Phase 2 (Option B): announce an OTA status TRANSITION to the server (wired by
|
||||||
|
// MainActivity to WebSocketService.sendOtaStatus, which reads the just-persisted state).
|
||||||
|
// Fired ONLY at the two transitions — clear and enter-backoff — so the dashboard badge
|
||||||
|
// updates promptly without waiting for a reconnect, with no per-poll/heartbeat chatter.
|
||||||
|
// Lazy/null-safe so binding order doesn't matter, same as otaLogReporter.
|
||||||
|
var otaStatusReporter: (() -> Unit)? = null
|
||||||
|
private fun announceOtaStatus() { try { otaStatusReporter?.invoke() } catch (_: Throwable) {} }
|
||||||
|
|
||||||
// The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION,
|
// The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION,
|
||||||
// which Android 13+ returns for non-device-owner installers) via this broadcast.
|
// which Android 13+ returns for non-device-owner installers) via this broadcast.
|
||||||
// Without handling it the committed session just stalls and the update never
|
// Without handling it the committed session just stalls and the update never
|
||||||
|
|
@ -59,6 +78,8 @@ class UpdateChecker(private val context: Context) {
|
||||||
catch (e: Exception) { Log.e(TAG, "Confirm launch failed: ${e.message}") }
|
catch (e: Exception) { Log.e(TAG, "Confirm launch failed: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Logcat only — NOT report(): these fire per attempt, and #139 keeps the
|
||||||
|
// device:log/dashboard channel to state transitions (enter-backoff, clear).
|
||||||
android.content.pm.PackageInstaller.STATUS_SUCCESS -> Log.i(TAG, "Update installed successfully")
|
android.content.pm.PackageInstaller.STATUS_SUCCESS -> Log.i(TAG, "Update installed successfully")
|
||||||
else -> Log.w(TAG, "Install status: ${intent.getStringExtra(android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE)}")
|
else -> Log.w(TAG, "Install status: ${intent.getStringExtra(android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE)}")
|
||||||
}
|
}
|
||||||
|
|
@ -116,9 +137,17 @@ class UpdateChecker(private val context: Context) {
|
||||||
|
|
||||||
Log.i(TAG, "Current: $currentVersion, Latest: $latestVersion, Update: $updateAvailable")
|
Log.i(TAG, "Current: $currentVersion, Latest: $latestVersion, Update: $updateAvailable")
|
||||||
|
|
||||||
if (updateAvailable && downloadUrl.isNotEmpty()) {
|
if (!updateAvailable) {
|
||||||
Log.i(TAG, "Update available! Downloading...")
|
// #139: on the latest version now. If OTA state was pending, the install
|
||||||
downloadAndInstall("${config.serverUrl}$downloadUrl", latestVersion)
|
// landed (the app relaunched as the new version) — clear state + caches once.
|
||||||
|
if (OtaThrottle.shouldClearOnUpToDate(otaState())) {
|
||||||
|
report("info", "OTA complete: now on $currentVersion — clearing update state")
|
||||||
|
config.clearOtaState()
|
||||||
|
cleanupApks(null)
|
||||||
|
announceOtaStatus() // transition -> emits 'none' so the badge clears promptly
|
||||||
|
}
|
||||||
|
} else if (downloadUrl.isNotEmpty()) {
|
||||||
|
maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Update check error: ${e.message}")
|
Log.e(TAG, "Update check error: ${e.message}")
|
||||||
|
|
@ -126,20 +155,89 @@ class UpdateChecker(private val context: Context) {
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadAndInstall(url: String, version: String) {
|
private fun otaState() = OtaThrottle.State(
|
||||||
|
config.otaTargetVersion, config.otaAttempts, config.otaLastAttemptAt, config.otaBackoffReported)
|
||||||
|
|
||||||
|
private fun persistOta(s: OtaThrottle.State) {
|
||||||
|
config.otaTargetVersion = s.targetVersion
|
||||||
|
config.otaAttempts = s.attempts
|
||||||
|
config.otaLastAttemptAt = s.lastAttemptAt
|
||||||
|
config.otaBackoffReported = s.backoffReported
|
||||||
|
}
|
||||||
|
|
||||||
|
// #139 imperative shell over OtaThrottle (the pure, unit-tested decision logic). A device
|
||||||
|
// that can't silently install (Fire TV: no device-owner) stops re-pulling the full APK every
|
||||||
|
// cycle. Only a COMMITTED install consumes the attempt budget — a transient download/verify
|
||||||
|
// failure on a HEALTHY device must never park it in backoff.
|
||||||
|
private fun maybeUpdate(latestVersion: String, downloadUrl: String) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val cur = otaState()
|
||||||
|
if (OtaThrottle.isNewTarget(cur, latestVersion)) cleanupApks(latestVersion)
|
||||||
|
|
||||||
|
val (afterCheck, action) = OtaThrottle.onUpdateAvailable(cur, latestVersion, now)
|
||||||
|
persistOta(afterCheck)
|
||||||
|
// Capped + still inside the window: do nothing AND stay silent. Fire OS restarts re-fire
|
||||||
|
// this check constantly; reporting here would just move the flood onto the WS channel.
|
||||||
|
// The enter-backoff line was already sent once on the crossing (below).
|
||||||
|
if (action == OtaThrottle.Action.BACKOFF) return
|
||||||
|
|
||||||
|
// download/verify failure → retry on the normal cadence; do NOT count it as an attempt.
|
||||||
|
if (!downloadAndInstall(downloadUrl, latestVersion)) {
|
||||||
|
Log.w(TAG, "Update $latestVersion: download/verify failed — retry next check (no attempt consumed)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val (afterLaunch, enteredBackoff) = OtaThrottle.onInstallLaunched(afterCheck, now)
|
||||||
|
persistOta(afterLaunch)
|
||||||
|
Log.i(TAG, "Install launched for $latestVersion (attempt ${afterLaunch.attempts}/${OtaThrottle.MAX_INSTALL_ATTEMPTS})")
|
||||||
|
if (enteredBackoff) {
|
||||||
|
report("warn", "Update $latestVersion available but not installing after ${afterLaunch.attempts} attempts — manual update required (backing off to one retry per ${OtaThrottle.BACKOFF_MS / 3_600_000L}h)")
|
||||||
|
announceOtaStatus() // transition -> emits 'manual_update_required'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #139: remove cached OTA APKs other than `keep` (null = remove all). Keeps the external
|
||||||
|
// files dir from accumulating one stale APK per superseded version.
|
||||||
|
private fun cleanupApks(keep: String?) {
|
||||||
try {
|
try {
|
||||||
|
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return
|
||||||
|
val keepName = keep?.let { "ScreenTinker-$it.apk" }
|
||||||
|
dir.listFiles { f ->
|
||||||
|
f.name.startsWith("ScreenTinker-") && f.name.endsWith(".apk") && f.name != keepName
|
||||||
|
}?.forEach { it.delete() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "APK cleanup failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns TRUE only when a verified APK is in hand and an install has been launched (the
|
||||||
|
// caller may then count an attempt); FALSE on any download/verify failure — the caller must
|
||||||
|
// NOT count those, so a transient network problem can't burn a healthy device's budget. #139
|
||||||
|
private fun downloadAndInstall(url: String, version: String): Boolean {
|
||||||
|
try {
|
||||||
|
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||||
|
"ScreenTinker-$version.apk")
|
||||||
|
|
||||||
|
// #139: reuse a previously-downloaded, verified APK for this version instead of
|
||||||
|
// re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a
|
||||||
|
// manual install when silent install isn't possible.
|
||||||
|
if (apkFile.exists() && verifyApkSignature(apkFile)) {
|
||||||
|
Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
|
||||||
|
handler.post { installApk(apkFile) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A leftover but invalid file (partial/corrupt/tampered) must never be reused.
|
||||||
|
if (apkFile.exists()) apkFile.delete()
|
||||||
|
|
||||||
// Download to a temp file
|
// Download to a temp file
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
Log.e(TAG, "Download failed: ${response.code}")
|
Log.e(TAG, "Download failed: ${response.code}")
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
|
||||||
"ScreenTinker-$version.apk")
|
|
||||||
|
|
||||||
response.body?.byteStream()?.use { input ->
|
response.body?.byteStream()?.use { input ->
|
||||||
apkFile.outputStream().use { output ->
|
apkFile.outputStream().use { output ->
|
||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
|
|
@ -158,7 +256,7 @@ class UpdateChecker(private val context: Context) {
|
||||||
if (!verifyApkSignature(apkFile)) {
|
if (!verifyApkSignature(apkFile)) {
|
||||||
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
|
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
|
||||||
apkFile.delete()
|
apkFile.delete()
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
Log.i(TAG, "APK signature verified against installed app - proceeding to install")
|
Log.i(TAG, "APK signature verified against installed app - proceeding to install")
|
||||||
|
|
||||||
|
|
@ -166,8 +264,10 @@ class UpdateChecker(private val context: Context) {
|
||||||
handler.post {
|
handler.post {
|
||||||
installApk(apkFile)
|
installApk(apkFile)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Download/install error: ${e.message}")
|
Log.e(TAG, "Download/install error: ${e.message}")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,9 +345,18 @@ class UpdateChecker(private val context: Context) {
|
||||||
private fun verifyApkSignature(apkFile: File): Boolean {
|
private fun verifyApkSignature(apkFile: File): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
// #139: getPackageArchiveInfo(GET_SIGNING_CERTIFICATES).signingInfo is NULL for
|
||||||
|
// ARCHIVE files on API 28/29 (it's only populated from API 30) — so the modern flag
|
||||||
|
// reads 0 certs from a downloaded APK and we'd wrongly REFUSE a legitimate update,
|
||||||
|
// which is the real Fire OS 8 / Android 9 OTA-loop cause. Below API 30, read the
|
||||||
|
// archive's signer via the legacy GET_SIGNATURES + .signatures (its v1/JAR cert,
|
||||||
|
// which IS populated on 28/29). This reads the cert CORRECTLY — it does not weaken
|
||||||
|
// verification: the archive's signer is still extracted and compared to the installed
|
||||||
|
// app's signer below, and a mismatch / zero-cert APK is still rejected.
|
||||||
|
val archiveUsesSigningInfo = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // API 30
|
||||||
|
val archiveFlags = if (archiveUsesSigningInfo)
|
||||||
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||||
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, flags)
|
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags)
|
||||||
if (downloaded == null) {
|
if (downloaded == null) {
|
||||||
Log.e(TAG, "Could not parse downloaded APK")
|
Log.e(TAG, "Could not parse downloaded APK")
|
||||||
return false
|
return false
|
||||||
|
|
@ -256,14 +365,20 @@ class UpdateChecker(private val context: Context) {
|
||||||
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
|
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val installed = pm.getPackageInfo(context.packageName, flags)
|
// INSTALLED-app read: signingInfo IS populated for installed packages on API 28+,
|
||||||
val downloadedSigs = signingCertHashes(downloaded)
|
// so keep the modern flag there (this side already worked).
|
||||||
val installedSigs = signingCertHashes(installed)
|
val installedUsesSigningInfo = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P // API 28
|
||||||
|
val installedFlags = if (installedUsesSigningInfo)
|
||||||
|
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||||
|
val installed = pm.getPackageInfo(context.packageName, installedFlags)
|
||||||
|
val downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
|
||||||
|
val installedSigs = signingCertHashes(installed, installedUsesSigningInfo)
|
||||||
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
|
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
|
||||||
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
|
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Share at least one current signing certificate.
|
// Require a non-empty overlap of signer certs (handles multi-signer / cert-rotation
|
||||||
|
// the same way the API>=30 path does: compare the full current signer sets).
|
||||||
val match = downloadedSigs.any { it in installedSigs }
|
val match = downloadedSigs.any { it in installedSigs }
|
||||||
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
|
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
|
||||||
match
|
match
|
||||||
|
|
@ -273,8 +388,13 @@ class UpdateChecker(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun signingCertHashes(info: PackageInfo): Set<String> {
|
// Read the signer-cert SHA-256 set from a PackageInfo. `useSigningInfo` must match the flag
|
||||||
val sigs: Array<Signature>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
// it was fetched with: GET_SIGNING_CERTIFICATES -> signingInfo.apkContentsSigners (modern;
|
||||||
|
// multi-signer + rotation aware), GET_SIGNATURES -> legacy .signatures (the only field
|
||||||
|
// populated for ARCHIVE reads on API 28/29). Both yield the same cert for a normally-signed
|
||||||
|
// APK; the caller compares as sets so an overlapping signer still verifies.
|
||||||
|
private fun signingCertHashes(info: PackageInfo, useSigningInfo: Boolean): Set<String> {
|
||||||
|
val sigs: Array<Signature>? = if (useSigningInfo) {
|
||||||
info.signingInfo?.apkContentsSigners
|
info.signingInfo?.apkContentsSigners
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION") info.signatures
|
@Suppress("DEPRECATION") info.signatures
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,22 @@ class WebSocketService : Service() {
|
||||||
} catch (e: Throwable) { Log.w("WebSocketService", "sendLog: ${e.message}") }
|
} catch (e: Throwable) { Log.w("WebSocketService", "sendLog: ${e.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #139 Phase 2 (Option B): announce an OTA status transition to the server so the dashboard
|
||||||
|
// badge updates promptly (not only on reconnect). Reads the just-persisted throttle state —
|
||||||
|
// the emit always reflects the stored truth. Called by UpdateChecker at clear / enter-backoff.
|
||||||
|
fun sendOtaStatus() {
|
||||||
|
if (socket?.connected() != true) return
|
||||||
|
try {
|
||||||
|
val s = OtaThrottle.State(config.otaTargetVersion, config.otaAttempts, config.otaLastAttemptAt, config.otaBackoffReported)
|
||||||
|
socket?.emit("device:ota-status", JSONObject().apply {
|
||||||
|
put("device_id", config.deviceId)
|
||||||
|
put("ota_status", OtaThrottle.statusFor(s, System.currentTimeMillis()))
|
||||||
|
put("ota_target_version", config.otaTargetVersion)
|
||||||
|
put("ota_attempts", config.otaAttempts)
|
||||||
|
})
|
||||||
|
} catch (e: Throwable) { Log.w("WebSocketService", "sendOtaStatus: ${e.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
fun sendPlaybackState(contentId: String, positionSec: Float) {
|
fun sendPlaybackState(contentId: String, positionSec: Float) {
|
||||||
if (socket?.connected() != true) return
|
if (socket?.connected() != true) return
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import android.os.SystemClock
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.DisplayMetrics
|
import android.util.DisplayMetrics
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import com.remotedisplay.player.data.ServerConfig
|
||||||
|
import com.remotedisplay.player.service.OtaThrottle
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
|
@ -49,6 +51,13 @@ class DeviceInfo(private val context: Context) {
|
||||||
put("screen_height", outH)
|
put("screen_height", outH)
|
||||||
put("render_width", renW)
|
put("render_width", renW)
|
||||||
put("render_height", renH)
|
put("render_height", renH)
|
||||||
|
// #139 Phase 2: report OTA backoff state (alongside app_version) so the dashboard can
|
||||||
|
// flag screens stuck in manual-update-required. Read from the persisted throttle state.
|
||||||
|
val cfg = ServerConfig(context)
|
||||||
|
val ota = OtaThrottle.State(cfg.otaTargetVersion, cfg.otaAttempts, cfg.otaLastAttemptAt, cfg.otaBackoffReported)
|
||||||
|
put("ota_status", OtaThrottle.statusFor(ota, System.currentTimeMillis()))
|
||||||
|
put("ota_target_version", cfg.otaTargetVersion)
|
||||||
|
put("ota_attempts", cfg.otaAttempts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,18 @@ object WebViewSupport {
|
||||||
* HTML wrapper for a YouTube embed. Loaded via loadDataWithBaseURL(YT_BASE, ...)
|
* HTML wrapper for a YouTube embed. Loaded via loadDataWithBaseURL(YT_BASE, ...)
|
||||||
* so the iframe has a valid youtube.com origin/referer (a bare loadUrl of the
|
* so the iframe has a valid youtube.com origin/referer (a bare loadUrl of the
|
||||||
* embed gives Error 153 "player misconfigured"). Returns null if no video id.
|
* embed gives Error 153 "player misconfigured"). Returns null if no video id.
|
||||||
|
*
|
||||||
|
* #129: the initial mute now comes from the per-item [muted] flag (was hardcoded
|
||||||
|
* mute=1, which made YouTube un-unmuteable). The WebView sets
|
||||||
|
* mediaPlaybackRequiresUserGesture=false, so mute=0 still autoplays WITH audio.
|
||||||
|
* enablejsapi=1 lets the live device:mute-changed toggle drive the player via the
|
||||||
|
* IFrame API postMessage bridge (MediaPlayerManager.setYoutubeMuted) without a
|
||||||
|
* flicker-y reload.
|
||||||
*/
|
*/
|
||||||
fun youtubeEmbedHtml(url: String): String? {
|
fun youtubeEmbedHtml(url: String, muted: Boolean = true): String? {
|
||||||
val id = extractYoutubeId(url) ?: return null
|
val id = extractYoutubeId(url) ?: return null
|
||||||
val src = "$YT_BASE/embed/$id?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1"
|
val mute = if (muted) 1 else 0
|
||||||
|
val src = "$YT_BASE/embed/$id?autoplay=1&mute=$mute&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1&enablejsapi=1"
|
||||||
return "<!DOCTYPE html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
|
return "<!DOCTYPE html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
|
||||||
"<style>html,body{margin:0;padding:0;height:100%;background:#000;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>" +
|
"<style>html,body{margin:0;padding:0;height:100%;background:#000;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>" +
|
||||||
"</head><body><iframe src=\"$src\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe></body></html>"
|
"</head><body><iframe src=\"$src\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe></body></html>"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package com.remotedisplay.player.service
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #139: coverage for the OTA throttle state machine (the stateful core that the OTA
|
||||||
|
* re-download-loop fix depends on), independent of Android. UpdateChecker is just the shell.
|
||||||
|
*/
|
||||||
|
class OtaThrottleTest {
|
||||||
|
|
||||||
|
private val V = "1.9.1-beta6"
|
||||||
|
private val MAX = OtaThrottle.MAX_INSTALL_ATTEMPTS
|
||||||
|
private val WINDOW = OtaThrottle.BACKOFF_MS
|
||||||
|
|
||||||
|
// Launch `n` installs from `start`, returning the resulting state.
|
||||||
|
private fun launch(start: OtaThrottle.State, n: Int, now: Long = 1000L): OtaThrottle.State {
|
||||||
|
var s = start
|
||||||
|
repeat(n) { s = OtaThrottle.onInstallLaunched(s, now + it).first }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun newTargetResetsBudget() {
|
||||||
|
val stale = OtaThrottle.State(targetVersion = "1.9.1-beta5", attempts = 2, lastAttemptAt = 1000, backoffReported = true)
|
||||||
|
assertTrue(OtaThrottle.isNewTarget(stale, V))
|
||||||
|
val (s, action) = OtaThrottle.onUpdateAvailable(stale, V, now = 5000)
|
||||||
|
assertEquals(V, s.targetVersion)
|
||||||
|
assertEquals(0, s.attempts)
|
||||||
|
assertEquals(0L, s.lastAttemptAt)
|
||||||
|
assertFalse(s.backoffReported)
|
||||||
|
assertEquals(OtaThrottle.Action.ATTEMPT, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aCheckNeverConsumesBudget_onlyInstallLaunchedDoes() {
|
||||||
|
var s = OtaThrottle.State(targetVersion = V, attempts = 0)
|
||||||
|
// Repeated checks (e.g. each followed by a failed download) must not advance the counter.
|
||||||
|
repeat(5) {
|
||||||
|
val (ns, action) = OtaThrottle.onUpdateAvailable(s, V, now = 100)
|
||||||
|
assertEquals(OtaThrottle.Action.ATTEMPT, action)
|
||||||
|
assertEquals(0, ns.attempts)
|
||||||
|
s = ns
|
||||||
|
}
|
||||||
|
// Only a launched install increments.
|
||||||
|
assertEquals(1, OtaThrottle.onInstallLaunched(s, now = 200).first.attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun capThenBackoffWithinWindow() {
|
||||||
|
val s = launch(OtaThrottle.State(targetVersion = V), MAX, now = 1000L)
|
||||||
|
assertEquals(MAX, s.attempts)
|
||||||
|
assertTrue(s.backoffReported)
|
||||||
|
// A check inside the window → BACKOFF, no further attempt, state unchanged.
|
||||||
|
val (ns, action) = OtaThrottle.onUpdateAvailable(s, V, now = 1000L + WINDOW - 1)
|
||||||
|
assertEquals(OtaThrottle.Action.BACKOFF, action)
|
||||||
|
assertEquals(MAX, ns.attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun enterBackoffSignalsExactlyOnce() {
|
||||||
|
var s = OtaThrottle.State(targetVersion = V)
|
||||||
|
var crossings = 0
|
||||||
|
repeat(MAX + 3) { i ->
|
||||||
|
val (ns, entered) = OtaThrottle.onInstallLaunched(s, now = i.toLong())
|
||||||
|
if (entered) crossings++
|
||||||
|
s = ns
|
||||||
|
}
|
||||||
|
assertEquals("enter-backoff fires only on the crossing", 1, crossings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun retryAfterWindowElapsedDoesNotReReport() {
|
||||||
|
val capped = OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = 0L, backoffReported = true)
|
||||||
|
val (afterCheck, action) = OtaThrottle.onUpdateAvailable(capped, V, now = WINDOW + 1)
|
||||||
|
assertEquals(OtaThrottle.Action.ATTEMPT, action) // window elapsed → one retry allowed
|
||||||
|
val (_, entered) = OtaThrottle.onInstallLaunched(afterCheck, now = WINDOW + 2)
|
||||||
|
assertFalse("already reported entering backoff — must not report again", entered)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun clearsOnSuccessOnlyWhenPending() {
|
||||||
|
assertTrue(OtaThrottle.shouldClearOnUpToDate(OtaThrottle.State(targetVersion = V, attempts = 2)))
|
||||||
|
assertFalse(OtaThrottle.shouldClearOnUpToDate(OtaThrottle.State())) // nothing pending
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun statusForReflectsBackoffWindow() {
|
||||||
|
val now = 10_000L
|
||||||
|
// no target → none
|
||||||
|
assertEquals("none", OtaThrottle.statusFor(OtaThrottle.State(), now))
|
||||||
|
// under the cap → pending
|
||||||
|
assertEquals("pending", OtaThrottle.statusFor(
|
||||||
|
OtaThrottle.State(targetVersion = V, attempts = 1, lastAttemptAt = now), now))
|
||||||
|
// capped AND inside the window → manual update required
|
||||||
|
assertEquals("manual_update_required", OtaThrottle.statusFor(
|
||||||
|
OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = now), now + WINDOW - 1))
|
||||||
|
// capped but window elapsed (a retry is due) → pending, not stuck
|
||||||
|
assertEquals("pending", OtaThrottle.statusFor(
|
||||||
|
OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = now), now + WINDOW + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
44
docs/maintenance-device-status-log.md
Normal file
44
docs/maintenance-device-status-log.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Maintenance: `device_status_log` growth & space reclaim (#142)
|
||||||
|
|
||||||
|
## What changed in 1.9.2-beta1
|
||||||
|
|
||||||
|
`device_status_log` previously grew without an effective bound (the per-device
|
||||||
|
insert-time prune missed removed/idle devices and the heartbeat `offline_timeout`
|
||||||
|
insert). In one deployment it reached ~1.2M rows / ~119 MB over ~23 days and
|
||||||
|
degraded dashboard performance.
|
||||||
|
|
||||||
|
1.9.2-beta1 bounds further growth:
|
||||||
|
|
||||||
|
- **Index** `idx_device_status_log_device_ts(device_id, timestamp)` — the dashboard
|
||||||
|
uptime query and the prunes now use an index instead of a full scan.
|
||||||
|
- **Global retention sweep** (`pruneStatusLog()`), run on startup and on the
|
||||||
|
heartbeat interval, deletes rows older than **`STATUS_LOG_RETENTION_DAYS`**
|
||||||
|
(default **3**) across *all* devices — including removed/idle devices and the
|
||||||
|
`offline_timeout` rows the per-device prune never revisited.
|
||||||
|
|
||||||
|
## Reclaiming space on an already-bloated database
|
||||||
|
|
||||||
|
> **Operator action — only needed once, only if your `device_status_log` is already
|
||||||
|
> bloated from a pre-1.9.2 deployment.**
|
||||||
|
|
||||||
|
Retention bounds *future* growth, but SQLite does **not** return freed pages to the
|
||||||
|
filesystem on `DELETE` — the file stays at its high-water mark until a `VACUUM`.
|
||||||
|
After upgrading (which prunes the old rows), reclaim the disk with a **one-time
|
||||||
|
manual `VACUUM` in a maintenance window**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# stop the server (or do this during a low-traffic window — VACUUM takes a global
|
||||||
|
# write lock and rewrites the whole DB file; the app cannot write during it)
|
||||||
|
sqlite3 /opt/screentinker/server/db/remote_display.db 'VACUUM;'
|
||||||
|
```
|
||||||
|
|
||||||
|
In the reference incident this took the DB from **119 MB → 39 MB**.
|
||||||
|
|
||||||
|
### Why VACUUM is not automatic
|
||||||
|
|
||||||
|
`VACUUM` locks the database and rewrites the entire file — unacceptable on the hot
|
||||||
|
path. `PRAGMA auto_vacuum=INCREMENTAL` is **not** enabled either: it only takes
|
||||||
|
effect on a freshly-created database (set before the first table) or after a
|
||||||
|
one-time full `VACUUM` to convert an existing DB, so enabling it would be a no-op on
|
||||||
|
existing installs and a silent behavior change on new ones. Space reclaim is left as
|
||||||
|
a deliberate operator decision; ongoing growth is already bounded by retention.
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
// standard for B2B software in DACH). Native review recommended before
|
// standard for B2B software in DACH). Native review recommended before
|
||||||
// publicizing as fully supported.
|
// publicizing as fully supported.
|
||||||
export default {
|
export default {
|
||||||
|
// #zone-orphan dashboard warnings
|
||||||
|
'device.pl_item.orphan_zone': 'Zone aus einem anderen Layout — neu zuweisen',
|
||||||
|
'device.pl_item.orphan_zone_tip': 'Die Zone dieses Elements gehört nicht zum aktuellen Layout des Geräts. Es wird weiterhin abgespielt (in die größte Zone verschoben), sollte aber einer Zone dieses Layouts neu zugewiesen werden.',
|
||||||
|
'dashboard.device_orphan_tip_one': '{n} Element ist einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen',
|
||||||
|
'dashboard.device_orphan_tip_other': '{n} Elemente sind einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen',
|
||||||
// Nav
|
// Nav
|
||||||
'nav.displays': 'Bildschirme',
|
'nav.displays': 'Bildschirme',
|
||||||
'nav.content': 'Inhalt',
|
'nav.content': 'Inhalt',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
// English translations. This file is the source of truth for keys —
|
// English translations. This file is the source of truth for keys —
|
||||||
// every other locale should mirror its keys (or fall back to en).
|
// every other locale should mirror its keys (or fall back to en).
|
||||||
export default {
|
export default {
|
||||||
|
// #zone-orphan dashboard warnings
|
||||||
|
'device.pl_item.orphan_zone': 'Zone from a different layout — reassign',
|
||||||
|
'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.",
|
||||||
|
'dashboard.device_orphan_tip_one': "{n} item assigned to a zone that isn't in this device's layout — open the device to reassign",
|
||||||
|
'dashboard.device_orphan_tip_other': "{n} items assigned to a zone that isn't in this device's layout — open the device to reassign",
|
||||||
|
// #139: device stuck in OTA backoff (can't self-install — e.g. Fire TV) — needs a manual update.
|
||||||
|
'dashboard.device_ota_stuck': 'Update available (v{version}) — install failed {n}×, manual update required',
|
||||||
// Nav (sidebar)
|
// Nav (sidebar)
|
||||||
'nav.displays': 'Displays',
|
'nav.displays': 'Displays',
|
||||||
'nav.content': 'Content',
|
'nav.content': 'Content',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
// Spanish translations. Reviewed for UI register (informal tú).
|
// Spanish translations. Reviewed for UI register (informal tú).
|
||||||
// Native review still recommended before publicizing as fully supported.
|
// Native review still recommended before publicizing as fully supported.
|
||||||
export default {
|
export default {
|
||||||
|
// #zone-orphan dashboard warnings
|
||||||
|
'device.pl_item.orphan_zone': 'Zona de otro diseño — reasignar',
|
||||||
|
'device.pl_item.orphan_zone_tip': 'La zona de este elemento no pertenece al diseño actual del dispositivo. Se sigue reproduciendo (recuperado en la zona más grande), pero reasígnalo a una zona de este diseño.',
|
||||||
|
'dashboard.device_orphan_tip_one': '{n} elemento asignado a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar',
|
||||||
|
'dashboard.device_orphan_tip_other': '{n} elementos asignados a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar',
|
||||||
// Nav
|
// Nav
|
||||||
'nav.displays': 'Pantallas',
|
'nav.displays': 'Pantallas',
|
||||||
'nav.content': 'Contenido',
|
'nav.content': 'Contenido',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
// standard for software UIs in France; tu would feel underdressed for a B2B tool).
|
// standard for software UIs in France; tu would feel underdressed for a B2B tool).
|
||||||
// Native review recommended before publicizing as fully supported.
|
// Native review recommended before publicizing as fully supported.
|
||||||
export default {
|
export default {
|
||||||
|
// #zone-orphan dashboard warnings
|
||||||
|
'device.pl_item.orphan_zone': 'Zone d\'une autre mise en page — réattribuer',
|
||||||
|
'device.pl_item.orphan_zone_tip': 'La zone de cet élément ne fait pas partie de la mise en page actuelle de l\'appareil. Il continue de s\'afficher (récupéré dans la plus grande zone), mais réattribuez-le à une zone de cette mise en page.',
|
||||||
|
'dashboard.device_orphan_tip_one': '{n} élément attribué à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour le réattribuer',
|
||||||
|
'dashboard.device_orphan_tip_other': '{n} éléments attribués à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour les réattribuer',
|
||||||
// Nav
|
// Nav
|
||||||
'nav.displays': 'Écrans',
|
'nav.displays': 'Écrans',
|
||||||
'nav.content': 'Contenu',
|
'nav.content': 'Contenu',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
// Italian translations. This file is the source of truth for keys —
|
// Italian translations. This file is the source of truth for keys —
|
||||||
// every other locale should mirror its keys (or fall back to en).
|
// every other locale should mirror its keys (or fall back to en).
|
||||||
export default {
|
export default {
|
||||||
|
// #zone-orphan dashboard warnings
|
||||||
|
'device.pl_item.orphan_zone': 'Zona di un altro layout — riassegna',
|
||||||
|
'device.pl_item.orphan_zone_tip': 'La zona di questo elemento non fa parte del layout attuale del dispositivo. Continua a essere riprodotto (recuperato nella zona più grande), ma riassegnalo a una zona di questo layout.',
|
||||||
|
'dashboard.device_orphan_tip_one': '{n} elemento assegnato a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarlo',
|
||||||
|
'dashboard.device_orphan_tip_other': '{n} elementi assegnati a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarli',
|
||||||
// Nav (sidebar)
|
// Nav (sidebar)
|
||||||
'nav.displays': 'Schermi',
|
'nav.displays': 'Schermi',
|
||||||
'nav.content': 'Contenuti',
|
'nav.content': 'Contenuti',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
// Reviewed for UI register (informal você). Native review recommended before
|
// Reviewed for UI register (informal você). Native review recommended before
|
||||||
// publicizing as fully supported.
|
// publicizing as fully supported.
|
||||||
export default {
|
export default {
|
||||||
|
// #zone-orphan dashboard warnings
|
||||||
|
'device.pl_item.orphan_zone': 'Zona de outro layout — reatribuir',
|
||||||
|
'device.pl_item.orphan_zone_tip': 'A zona deste item não faz parte do layout atual do dispositivo. Ele continua sendo reproduzido (recuperado na maior zona), mas reatribua-o a uma zona deste layout.',
|
||||||
|
'dashboard.device_orphan_tip_one': '{n} item atribuído a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir',
|
||||||
|
'dashboard.device_orphan_tip_other': '{n} itens atribuídos a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir',
|
||||||
// Nav
|
// Nav
|
||||||
'nav.displays': 'Telas',
|
'nav.displays': 'Telas',
|
||||||
'nav.content': 'Conteúdo',
|
'nav.content': 'Conteúdo',
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,13 @@ function renderDeviceCard(device) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-card-body">
|
<div class="device-card-body">
|
||||||
<div class="device-card-name">${esc(device.name)}</div>
|
<div class="device-card-name">${esc(device.name)}${device.orphan_count > 0 ? `
|
||||||
|
<span class="device-orphan-badge" title="${tn('dashboard.device_orphan_tip', device.orphan_count)}" style="margin-left:6px;display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--danger);vertical-align:middle">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>${device.orphan_count}
|
||||||
|
</span>` : ''}${device.ota_status === 'manual_update_required' ? `
|
||||||
|
<span class="device-ota-badge" title="${esc(t('dashboard.device_ota_stuck', { version: device.ota_target_version || '?', n: device.ota_attempts || 0 }))}" style="margin-left:6px;display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--warning);vertical-align:middle">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>update
|
||||||
|
</span>` : ''}</div>
|
||||||
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
|
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,10 @@ function renderPlaylist(assignments) {
|
||||||
${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` · ${a.duration_sec}s` : ''}
|
${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` · ${a.duration_sec}s` : ''}
|
||||||
${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''}
|
${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${a.orphan ? `<div class="pl-orphan-warning" data-orphan-assignment="${a.id}" title="${t('device.pl_item.orphan_zone_tip')}" style="margin-top:4px;font-size:11px;color:var(--danger);cursor:pointer;display:inline-flex;align-items:center;gap:4px">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
${t('device.pl_item.orphan_zone')}
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px">
|
<div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px">
|
||||||
<select class="input zone-select" data-assignment-id="${a.id}" data-current-zone-id="${a.zone_id || ''}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
|
<select class="input zone-select" data-assignment-id="${a.id}" data-current-zone-id="${a.zone_id || ''}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
|
||||||
|
|
@ -1202,40 +1206,60 @@ function attachRemoveHandlers(device) {
|
||||||
// Fetch errors are logged - the dropdowns simply stay hidden (display:none
|
// Fetch errors are logged - the dropdowns simply stay hidden (display:none
|
||||||
// is the default from the render), same end-state as before but no longer
|
// is the default from the render), same end-state as before but no longer
|
||||||
// silent.
|
// silent.
|
||||||
|
// Inline per-item zone reassign dropdowns. A device WITH a layout always gets them;
|
||||||
|
// visibility is NOT gated on whether the zone list arrived (gating on that silently hid
|
||||||
|
// the selector when the server payload lacked active_layout_zones — e.g. a stale server
|
||||||
|
// during a deploy skew). Only a genuinely fullscreen device (no layout_id) has no zones.
|
||||||
if (device.layout_id) {
|
if (device.layout_id) {
|
||||||
const token = localStorage.getItem('token');
|
// Show the selectors immediately; they become usable once zones populate below.
|
||||||
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }})
|
document.querySelectorAll('.zone-select').forEach(s => { s.style.display = ''; });
|
||||||
.then(r => {
|
// Clicking an item's orphan warning badge scrolls to + focuses its zone-select.
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
document.querySelectorAll('.pl-orphan-warning').forEach(w => {
|
||||||
return r.json();
|
w.addEventListener('click', () => {
|
||||||
})
|
const sel = document.querySelector('.zone-select[data-assignment-id="' + w.dataset.orphanAssignment + '"]');
|
||||||
.then(layout => {
|
if (sel) { sel.scrollIntoView({ block: 'center', behavior: 'smooth' }); sel.focus(); }
|
||||||
const zones = layout.zones || [];
|
|
||||||
document.querySelectorAll('.zone-select').forEach(select => {
|
|
||||||
select.style.display = '';
|
|
||||||
const assignmentId = select.dataset.assignmentId;
|
|
||||||
const currentZoneId = select.dataset.currentZoneId || '';
|
|
||||||
zones.forEach(z => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = z.id;
|
|
||||||
opt.textContent = z.name;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
if (currentZoneId) select.value = currentZoneId;
|
|
||||||
select.onchange = async () => {
|
|
||||||
try {
|
|
||||||
await api.updateAssignment(assignmentId, { zone_id: select.value || null });
|
|
||||||
showToast(t('device.toast.zone_updated'), 'success');
|
|
||||||
loadDevice(device.id, 'playlist');
|
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
|
||||||
};
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
// No toast - fires once per device-detail load, would be annoying for
|
|
||||||
// a layout misconfig that's already surfaced via the modal info row.
|
|
||||||
console.warn('Failed to load layout for edit-zone dropdowns:', e.message);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const populateZoneSelects = (zones) => {
|
||||||
|
const activeIds = new Set((zones || []).map(z => z.id));
|
||||||
|
document.querySelectorAll('.zone-select').forEach(select => {
|
||||||
|
select.style.display = '';
|
||||||
|
while (select.options.length > 1) select.remove(1); // keep the "no zone" placeholder, drop stale options
|
||||||
|
const assignmentId = select.dataset.assignmentId;
|
||||||
|
const currentZoneId = select.dataset.currentZoneId || '';
|
||||||
|
(zones || []).forEach(z => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = z.id;
|
||||||
|
opt.textContent = z.name;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
const orphan = !!currentZoneId && !activeIds.has(currentZoneId);
|
||||||
|
if (currentZoneId && !orphan) select.value = currentZoneId; // can't select a zone the layout lacks
|
||||||
|
if (orphan) { select.style.borderColor = 'var(--danger)'; select.style.color = 'var(--danger)'; }
|
||||||
|
select.onchange = async () => {
|
||||||
|
try {
|
||||||
|
await api.updateAssignment(assignmentId, { zone_id: select.value || null });
|
||||||
|
showToast(t('device.toast.zone_updated'), 'success');
|
||||||
|
loadDevice(device.id, 'playlist');
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (device.active_layout_zones && device.active_layout_zones.length) {
|
||||||
|
// Fast path: zones already in the device payload — no round-trip.
|
||||||
|
populateZoneSelects(device.active_layout_zones);
|
||||||
|
} else {
|
||||||
|
// Fallback: payload field absent/empty (server/frontend version skew, or a payload
|
||||||
|
// change) — fetch the layout so the dropdowns still populate. A missing field must
|
||||||
|
// never make the selector silently vanish.
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
|
||||||
|
.then(layout => populateZoneSelects(layout.zones || []))
|
||||||
|
.catch(e => console.warn('Zone dropdowns: layout fetch fallback failed:', e.message));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mute toggle buttons
|
// Mute toggle buttons
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,25 @@ if [ -n "$(git status --porcelain)" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Pre-push fast-forward guard. This script creates an annotated tag locally; if
|
||||||
|
# origin/main has advanced past the commit we're bumping from, `git push origin main`
|
||||||
|
# is rejected as a non-fast-forward - and if the tag gets pushed anyway it fires the
|
||||||
|
# release workflow from a commit that isn't even on main (the beta9 divergence
|
||||||
|
# incident). Catch the divergence HERE, before the tag exists, so nothing can fire.
|
||||||
|
# Best-effort: when the fetch can't run (offline), warn and proceed rather than block
|
||||||
|
# a local bump - the push itself is still the backstop.
|
||||||
|
if git fetch --quiet origin main 2>/dev/null; then
|
||||||
|
if ! git merge-base --is-ancestor FETCH_HEAD HEAD; then
|
||||||
|
echo "ERROR: origin/main ($(git rev-parse --short FETCH_HEAD)) has commits not in your" >&2
|
||||||
|
echo " HEAD ($(git rev-parse --short HEAD)) - 'git push origin main' would be rejected." >&2
|
||||||
|
echo " Merge origin/main into your branch first, then re-run the bump." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WARNING: could not fetch origin/main - skipping the fast-forward check (offline?)." >&2
|
||||||
|
echo " Confirm 'git push origin main' will fast-forward before pushing the tag." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
CURRENT="$(cat VERSION)"
|
CURRENT="$(cat VERSION)"
|
||||||
IFS=. read -r MAJ MIN PAT <<< "$CURRENT"
|
IFS=. read -r MAJ MIN PAT <<< "$CURRENT"
|
||||||
|
|
||||||
|
|
|
||||||
549
scripts/debian-13-setup.sh
Executable file
549
scripts/debian-13-setup.sh
Executable file
|
|
@ -0,0 +1,549 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ScreenTinker - Debian 13 Setup Script
|
||||||
|
#
|
||||||
|
# Modes:
|
||||||
|
# - Server + Player (both)
|
||||||
|
# - Server only
|
||||||
|
# - Player only
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -sSL https://screentinker.com/scripts/debian-13-setup.sh | sudo bash
|
||||||
|
# curl -sSL https://screentinker.com/scripts/debian-13-setup.sh | sudo bash -s -- --server-only
|
||||||
|
# curl -sSL https://screentinker.com/scripts/debian-13-setup.sh | sudo bash -s -- --player-only https://screentinker.com
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# -- Configuration --
|
||||||
|
SCREENTINKER_DIR="/opt/screentinker"
|
||||||
|
SCREENTINKER_PORT=3001
|
||||||
|
NODE_MAJOR=20
|
||||||
|
LOG_FILE="/var/log/screentinker-debian-setup.log"
|
||||||
|
|
||||||
|
# -- Colors --
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[ScreenTinker]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||||
|
err() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
MODE="both"
|
||||||
|
MODE_SET=false
|
||||||
|
SERVER_URL=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--server-only)
|
||||||
|
MODE="server"
|
||||||
|
MODE_SET=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--player-only)
|
||||||
|
MODE="player"
|
||||||
|
MODE_SET=true
|
||||||
|
shift
|
||||||
|
if [[ $# -gt 0 && "$1" == http* ]]; then
|
||||||
|
SERVER_URL="$1"
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
--both)
|
||||||
|
MODE="both"
|
||||||
|
MODE_SET=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: sudo ./debian-13-setup.sh [OPTIONS] [SERVER_URL]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --server-only Install only the server"
|
||||||
|
echo " --player-only [URL] Install only the player (URL required)"
|
||||||
|
echo " --both Install both server and player (default)"
|
||||||
|
echo " --help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " sudo ./debian-13-setup.sh"
|
||||||
|
echo " sudo ./debian-13-setup.sh --server-only"
|
||||||
|
echo " sudo ./debian-13-setup.sh --player-only https://screentinker.com"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
http*)
|
||||||
|
SERVER_URL="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
err "This script must be run as root. Try: sudo bash debian-13-setup.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
if [ "${ID:-}" != "debian" ] || [ "${VERSION_ID:-}" != "13" ]; then
|
||||||
|
warn "Detected ${PRETTY_NAME:-unknown}. This script targets Debian 13."
|
||||||
|
read -p "Continue anyway? (y/N) " -n 1 -r; echo
|
||||||
|
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
|
||||||
|
else
|
||||||
|
log "Detected Debian 13"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$MODE" = "player" ] && [ -z "$SERVER_URL" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}======================================${NC}"
|
||||||
|
echo -e "${BLUE} ScreenTinker Debian 13 Setup${NC}"
|
||||||
|
echo -e "${BLUE}======================================${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Server URL (e.g., https://screentinker.com): " SERVER_URL
|
||||||
|
elif [ "$MODE" = "both" ] && [ "$MODE_SET" = false ] && [ -z "$SERVER_URL" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}======================================${NC}"
|
||||||
|
echo -e "${BLUE} ScreenTinker Debian 13 Setup${NC}"
|
||||||
|
echo -e "${BLUE}======================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 1) Server + Player (recommended for single-screen host)"
|
||||||
|
echo " 2) Server Only"
|
||||||
|
echo " 3) Player Only"
|
||||||
|
echo ""
|
||||||
|
read -p "Choose [1/2/3]: " MODE_CHOICE
|
||||||
|
case "$MODE_CHOICE" in
|
||||||
|
2)
|
||||||
|
MODE="server"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
MODE="player"
|
||||||
|
read -p "Server URL (e.g., https://screentinker.com): " SERVER_URL
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
MODE="both"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
SERVER_URL="${SERVER_URL%/}"
|
||||||
|
|
||||||
|
NEED_SERVER=false
|
||||||
|
NEED_PLAYER=false
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
server)
|
||||||
|
NEED_SERVER=true
|
||||||
|
;;
|
||||||
|
player)
|
||||||
|
NEED_PLAYER=true
|
||||||
|
;;
|
||||||
|
both)
|
||||||
|
NEED_SERVER=true
|
||||||
|
NEED_PLAYER=true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "Unknown mode: $MODE"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$NEED_PLAYER" = true ] && [ "$MODE" = "player" ] && [ -z "$SERVER_URL" ]; then
|
||||||
|
err "Player-only mode requires a server URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_PLAYER" = true ]; then
|
||||||
|
if [ "$MODE" = "player" ]; then
|
||||||
|
KIOSK_URL="${SERVER_URL}/player"
|
||||||
|
else
|
||||||
|
KIOSK_URL="http://localhost:${SCREENTINKER_PORT}/player"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "Setup log: $LOG_FILE"
|
||||||
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||||
|
|
||||||
|
log "Updating system packages..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get upgrade -y -qq
|
||||||
|
|
||||||
|
log "Installing base dependencies..."
|
||||||
|
apt-get install -y -qq \
|
||||||
|
git curl wget unzip htop \
|
||||||
|
avahi-daemon \
|
||||||
|
fonts-liberation fonts-noto-color-emoji \
|
||||||
|
>> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
|
RUNTIME_USER="${SUDO_USER:-$(logname 2>/dev/null || echo root)}"
|
||||||
|
if ! id "$RUNTIME_USER" &>/dev/null; then
|
||||||
|
warn "Could not resolve invoking user; defaulting to root"
|
||||||
|
RUNTIME_USER="root"
|
||||||
|
fi
|
||||||
|
RUNTIME_HOME=$(eval echo "~$RUNTIME_USER")
|
||||||
|
|
||||||
|
if [ "$NEED_SERVER" = true ]; then
|
||||||
|
NEED_NODE=true
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
CUR=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||||
|
if [ "$CUR" -ge "$NODE_MAJOR" ]; then
|
||||||
|
log "Node.js $(node -v) already installed"
|
||||||
|
NEED_NODE=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_NODE" = true ]; then
|
||||||
|
log "Installing Node.js ${NODE_MAJOR}.x..."
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - >> "$LOG_FILE" 2>&1
|
||||||
|
apt-get install -y -qq nodejs >> "$LOG_FILE" 2>&1
|
||||||
|
log "Node.js $(node -v) installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$SCREENTINKER_DIR/.git" ]; then
|
||||||
|
log "Repo exists at $SCREENTINKER_DIR, pulling latest..."
|
||||||
|
cd "$SCREENTINKER_DIR" && git pull origin main >> "$LOG_FILE" 2>&1
|
||||||
|
else
|
||||||
|
log "Cloning ScreenTinker..."
|
||||||
|
git clone https://github.com/screentinker/screentinker.git "$SCREENTINKER_DIR" >> "$LOG_FILE" 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Installing server dependencies..."
|
||||||
|
cd "$SCREENTINKER_DIR/server"
|
||||||
|
npm install --production >> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
|
mkdir -p "$SCREENTINKER_DIR/server/db"
|
||||||
|
mkdir -p "$SCREENTINKER_DIR/server/uploads"
|
||||||
|
chown -R "$RUNTIME_USER":"$RUNTIME_USER" "$SCREENTINKER_DIR"
|
||||||
|
|
||||||
|
log "Creating screentinker-server service..."
|
||||||
|
cat > /etc/systemd/system/screentinker-server.service << SERVICEEOF
|
||||||
|
[Unit]
|
||||||
|
Description=ScreenTinker Digital Signage Server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${RUNTIME_USER}
|
||||||
|
WorkingDirectory=${SCREENTINKER_DIR}/server
|
||||||
|
ExecStart=/usr/bin/node server.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitBurst=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=${SCREENTINKER_PORT}
|
||||||
|
Environment=SELF_HOSTED=true
|
||||||
|
Environment=HOST=0.0.0.0
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=screentinker-server
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SERVICEEOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable screentinker-server.service
|
||||||
|
log "Server service enabled"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_PLAYER" = true ]; then
|
||||||
|
log "Installing player packages..."
|
||||||
|
apt-get install -y -qq \
|
||||||
|
xserver-xorg xserver-xorg-legacy x11-xserver-utils xinit \
|
||||||
|
chromium unclutter xdotool \
|
||||||
|
>> "$LOG_FILE" 2>&1 || {
|
||||||
|
warn "Failed to install chromium package, trying chromium-browser..."
|
||||||
|
apt-get install -y -qq xserver-xorg xserver-xorg-legacy x11-xserver-utils xinit chromium-browser unclutter xdotool >> "$LOG_FILE" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
CHROMIUM_BIN=$(command -v chromium 2>/dev/null || command -v chromium-browser 2>/dev/null || echo "/usr/bin/chromium")
|
||||||
|
|
||||||
|
log "Allowing non-root X server startup..."
|
||||||
|
mkdir -p /etc/X11
|
||||||
|
cat > /etc/X11/Xwrapper.config << 'XWRAPEOF'
|
||||||
|
allowed_users=anybody
|
||||||
|
needs_root_rights=yes
|
||||||
|
XWRAPEOF
|
||||||
|
|
||||||
|
log "Creating kiosk launcher..."
|
||||||
|
cat > "$RUNTIME_HOME/screentinker-kiosk.sh" << KIOSKEOF
|
||||||
|
#!/bin/bash
|
||||||
|
KIOSK_URL="${KIOSK_URL}"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Disable screen blanking and power management
|
||||||
|
xset s off
|
||||||
|
xset s noblank
|
||||||
|
xset -dpms
|
||||||
|
xset s 0 0
|
||||||
|
|
||||||
|
# Hide cursor after 3 seconds of inactivity
|
||||||
|
unclutter -idle 3 -root &
|
||||||
|
|
||||||
|
# Clean Chromium crash flags (prevents restore session dialogs)
|
||||||
|
CDIR="\$HOME/.config/chromium/Default"
|
||||||
|
mkdir -p "\$CDIR"
|
||||||
|
if [ -f "\$CDIR/Preferences" ]; then
|
||||||
|
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' "\$CDIR/Preferences" 2>/dev/null || true
|
||||||
|
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' "\$CDIR/Preferences" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for local server if running all-in-one
|
||||||
|
if echo "\$KIOSK_URL" | grep -q "localhost"; then
|
||||||
|
echo "Waiting for ScreenTinker server..."
|
||||||
|
for i in \$(seq 1 60); do
|
||||||
|
if curl -sf "http://localhost:${SCREENTINKER_PORT}/api/status" >/dev/null 2>&1; then
|
||||||
|
echo "Server ready after \${i}x2s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect screen resolution so Chromium fills the display on minimal X11 (no WM)
|
||||||
|
SCREEN_RES=\$(xrandr 2>/dev/null | grep ' connected' | grep -oE '[0-9]+x[0-9]+' | head -1)
|
||||||
|
SCREEN_W=\${SCREEN_RES%%x*}
|
||||||
|
SCREEN_H=\${SCREEN_RES##*x}
|
||||||
|
if [ -z "\$SCREEN_W" ] || [ -z "\$SCREEN_H" ]; then
|
||||||
|
SCREEN_W=1920
|
||||||
|
SCREEN_H=1080
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec ${CHROMIUM_BIN} \\
|
||||||
|
--kiosk \\
|
||||||
|
--window-position=0,0 \\
|
||||||
|
--window-size=\${SCREEN_W},\${SCREEN_H} \\
|
||||||
|
--noerrdialogs \\
|
||||||
|
--disable-infobars \\
|
||||||
|
--disable-session-crashed-bubble \\
|
||||||
|
--disable-features=TranslateUI \\
|
||||||
|
--disable-component-update \\
|
||||||
|
--check-for-update-interval=31536000 \\
|
||||||
|
--autoplay-policy=no-user-gesture-required \\
|
||||||
|
--no-first-run \\
|
||||||
|
--disable-pinch \\
|
||||||
|
--overscroll-history-navigation=0 \\
|
||||||
|
--disable-translate \\
|
||||||
|
--disable-sync \\
|
||||||
|
--disable-background-networking \\
|
||||||
|
--disable-default-apps \\
|
||||||
|
--disable-extensions \\
|
||||||
|
--disable-hang-monitor \\
|
||||||
|
--disable-popup-blocking \\
|
||||||
|
--disable-prompt-on-repost \\
|
||||||
|
--metrics-recording-only \\
|
||||||
|
--safebrowsing-disable-auto-update \\
|
||||||
|
--ignore-certificate-errors \\
|
||||||
|
"\$KIOSK_URL"
|
||||||
|
KIOSKEOF
|
||||||
|
|
||||||
|
chmod +x "$RUNTIME_HOME/screentinker-kiosk.sh"
|
||||||
|
chown "$RUNTIME_USER":"$RUNTIME_USER" "$RUNTIME_HOME/screentinker-kiosk.sh"
|
||||||
|
|
||||||
|
cat > "$RUNTIME_HOME/.xinitrc" << 'XINITEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
exec ~/screentinker-kiosk.sh
|
||||||
|
XINITEOF
|
||||||
|
chmod +x "$RUNTIME_HOME/.xinitrc"
|
||||||
|
chown "$RUNTIME_USER":"$RUNTIME_USER" "$RUNTIME_HOME/.xinitrc"
|
||||||
|
|
||||||
|
if [ "$NEED_SERVER" = true ]; then
|
||||||
|
KIOSK_AFTER="After=screentinker-server.service"
|
||||||
|
KIOSK_REQ="Requires=screentinker-server.service"
|
||||||
|
else
|
||||||
|
KIOSK_AFTER="After=network-online.target"
|
||||||
|
KIOSK_REQ="Wants=network-online.target"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Creating kiosk service..."
|
||||||
|
cat > /etc/systemd/system/screentinker-kiosk.service << SERVICEEOF
|
||||||
|
[Unit]
|
||||||
|
Description=ScreenTinker Kiosk Display
|
||||||
|
${KIOSK_AFTER}
|
||||||
|
${KIOSK_REQ}
|
||||||
|
# Prevent conflicts with getty on tty1
|
||||||
|
Conflicts=getty@tty1.service
|
||||||
|
After=getty@tty1.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${RUNTIME_USER}
|
||||||
|
Environment=DISPLAY=:0
|
||||||
|
Environment=XAUTHORITY=${RUNTIME_HOME}/.Xauthority
|
||||||
|
# Remove stale X lock files from previous crashes before starting
|
||||||
|
ExecStartPre=/bin/bash -c 'rm -f /tmp/.X0-lock /tmp/.X11-unix/X0'
|
||||||
|
ExecStartPre=/bin/sleep 3
|
||||||
|
ExecStart=/usr/bin/startx ${RUNTIME_HOME}/.xinitrc -- :0 -nolisten tcp vt1
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StartLimitBurst=5
|
||||||
|
StartLimitIntervalSec=120
|
||||||
|
|
||||||
|
TTYPath=/dev/tty1
|
||||||
|
StandardInput=tty
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=screentinker-kiosk
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SERVICEEOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable screentinker-kiosk.service
|
||||||
|
log "Kiosk service enabled"
|
||||||
|
|
||||||
|
log "Configuring auto-login on tty1..."
|
||||||
|
mkdir -p /etc/systemd/system/getty@tty1.service.d
|
||||||
|
cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << AUTOLOGINEOF
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=-/sbin/agetty --autologin ${RUNTIME_USER} --noclear %I \$TERM
|
||||||
|
AUTOLOGINEOF
|
||||||
|
|
||||||
|
# Disable getty on tty1 so it doesn't conflict with the kiosk service
|
||||||
|
systemctl disable getty@tty1.service 2>/dev/null || true
|
||||||
|
systemctl mask getty@tty1.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_SERVER" = true ]; then
|
||||||
|
log "Creating management scripts..."
|
||||||
|
|
||||||
|
cat > /usr/local/bin/screentinker-update << 'UPDATEEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
echo "Stopping services..."
|
||||||
|
sudo systemctl stop screentinker-kiosk.service 2>/dev/null || true
|
||||||
|
sudo systemctl stop screentinker-server.service 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Pulling latest..."
|
||||||
|
cd /opt/screentinker && git pull origin main
|
||||||
|
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
cd server && npm install --production
|
||||||
|
|
||||||
|
echo "Starting services..."
|
||||||
|
sudo systemctl start screentinker-server.service
|
||||||
|
if systemctl list-unit-files | grep -q '^screentinker-kiosk.service'; then
|
||||||
|
sleep 3
|
||||||
|
sudo systemctl start screentinker-kiosk.service
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Server: $(systemctl is-active screentinker-server.service)"
|
||||||
|
if systemctl list-unit-files | grep -q '^screentinker-kiosk.service'; then
|
||||||
|
echo " Kiosk: $(systemctl is-active screentinker-kiosk.service)"
|
||||||
|
fi
|
||||||
|
UPDATEEOF
|
||||||
|
chmod +x /usr/local/bin/screentinker-update
|
||||||
|
|
||||||
|
cat > /usr/local/bin/screentinker-status << 'STATUSEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
echo ""
|
||||||
|
echo "=== ScreenTinker Status ==="
|
||||||
|
echo ""
|
||||||
|
IP=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
if systemctl is-active screentinker-server.service &>/dev/null; then
|
||||||
|
echo "Server: RUNNING (PID $(systemctl show screentinker-server.service -p MainPID --value))"
|
||||||
|
else
|
||||||
|
echo "Server: STOPPED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if systemctl list-unit-files | grep -q '^screentinker-kiosk.service'; then
|
||||||
|
if systemctl is-active screentinker-kiosk.service &>/dev/null; then
|
||||||
|
echo "Kiosk: RUNNING"
|
||||||
|
else
|
||||||
|
echo "Kiosk: STOPPED"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Uptime: $(uptime -p)"
|
||||||
|
echo "Disk: $(df -h /opt/screentinker 2>/dev/null | tail -1 | awk '{print $3 "/" $2 " (" $5 " used)"}')"
|
||||||
|
echo "Memory: $(free -h | awk '/Mem:/ {print $3 " / " $2}')"
|
||||||
|
echo ""
|
||||||
|
echo "Dashboard: http://${IP}:3001"
|
||||||
|
echo "Player: http://${IP}:3001/player"
|
||||||
|
echo "mDNS: http://$(hostname).local:3001"
|
||||||
|
echo ""
|
||||||
|
STATUSEOF
|
||||||
|
chmod +x /usr/local/bin/screentinker-status
|
||||||
|
|
||||||
|
cat > /usr/local/bin/screentinker-logs << 'LOGSEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
case "${1:-server}" in
|
||||||
|
server) journalctl -u screentinker-server.service -f --no-hostname ;;
|
||||||
|
kiosk) journalctl -u screentinker-kiosk.service -f --no-hostname ;;
|
||||||
|
all) journalctl -u screentinker-server.service -u screentinker-kiosk.service -f --no-hostname ;;
|
||||||
|
*) echo "Usage: screentinker-logs [server|kiosk|all]" ;;
|
||||||
|
esac
|
||||||
|
LOGSEOF
|
||||||
|
chmod +x /usr/local/bin/screentinker-logs
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > /etc/motd << 'MOTDEOF'
|
||||||
|
|
||||||
|
____ _____ _
|
||||||
|
/ ___| ___ _ __ ___ ___ |_ _|_ _ __ | | _____ _ __
|
||||||
|
\___ \ / __| '__/ _ \/ _ \ | || | '_ \| |/ / _ \ '__|
|
||||||
|
___) | (__| | | __/ __/ | || | | | | < __/ |
|
||||||
|
|____/ \___|_| \___|\___| |_||_|_| |_|_|\_\___|_|
|
||||||
|
|
||||||
|
Open-Source Digital Signage for Any Screen
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
screentinker-status Show system info and URLs
|
||||||
|
screentinker-update Pull latest and restart
|
||||||
|
screentinker-logs Follow logs (server|kiosk|all)
|
||||||
|
|
||||||
|
MOTDEOF
|
||||||
|
|
||||||
|
if grep -q "#RuntimeWatchdogSec=0" /etc/systemd/system.conf 2>/dev/null; then
|
||||||
|
sed -i 's/#RuntimeWatchdogSec=0/RuntimeWatchdogSec=10/' /etc/systemd/system.conf
|
||||||
|
log "Hardware watchdog enabled (10s)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disable console blanking so the screen stays on during boot
|
||||||
|
if [ -f /etc/default/grub ]; then
|
||||||
|
if ! grep -q "consoleblank=0" /etc/default/grub; then
|
||||||
|
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"/GRUB_CMDLINE_LINUX_DEFAULT="\1 consoleblank=0"/' /etc/default/grub
|
||||||
|
update-grub >> "$LOG_FILE" 2>&1 && log "Console blanking disabled in GRUB" || warn "update-grub failed (non-fatal)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}======================================${NC}"
|
||||||
|
echo -e "${GREEN} ScreenTinker Setup Complete!${NC}"
|
||||||
|
echo -e "${GREEN}======================================${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
IP=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
if [ "$MODE" = "both" ]; then
|
||||||
|
echo "Mode: Server + Player"
|
||||||
|
echo "Dashboard: http://${IP}:${SCREENTINKER_PORT}"
|
||||||
|
echo "Player: http://${IP}:${SCREENTINKER_PORT}/player"
|
||||||
|
elif [ "$MODE" = "server" ]; then
|
||||||
|
echo "Mode: Server Only"
|
||||||
|
echo "Dashboard: http://${IP}:${SCREENTINKER_PORT}"
|
||||||
|
else
|
||||||
|
echo "Mode: Player Only"
|
||||||
|
echo "Server: $SERVER_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Services:"
|
||||||
|
if [ "$NEED_SERVER" = true ]; then
|
||||||
|
echo " sudo systemctl [start|stop|restart] screentinker-server"
|
||||||
|
fi
|
||||||
|
if [ "$NEED_PLAYER" = true ]; then
|
||||||
|
echo " sudo systemctl [start|stop|restart] screentinker-kiosk"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Reboot to start: sudo reboot${NC}"
|
||||||
|
echo ""
|
||||||
66
scripts/find-orphan-zone-items.js
Normal file
66
scripts/find-orphan-zone-items.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Report-only audit: find playlist_items whose zone_id is NOT a zone in the
|
||||||
|
* device's ACTIVE layout — i.e. orphaned cross-layout assignments. Un-patched
|
||||||
|
* players silently drop these; patched players (this branch) route them to the
|
||||||
|
* largest zone and emit a "zone" device-log warning. This script only REPORTS;
|
||||||
|
* it never mutates. Run it against a COPY of the prod DB.
|
||||||
|
*
|
||||||
|
* node scripts/find-orphan-zone-items.js [path/to/remote_display.db]
|
||||||
|
*
|
||||||
|
* Exit code is always 0 (it's a report); the count is printed.
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
let Database;
|
||||||
|
try {
|
||||||
|
Database = require('better-sqlite3');
|
||||||
|
} catch (e) {
|
||||||
|
// Resolve from the server's node_modules when run from the repo root.
|
||||||
|
Database = require(path.join(__dirname, '..', 'server', 'node_modules', 'better-sqlite3'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = process.argv[2] || path.join(__dirname, '..', 'server', 'db', 'remote_display.db');
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
|
||||||
|
// One row per (device, zoned item). A playlist shared by N devices is checked
|
||||||
|
// against EACH device's layout, since the same item can be valid for one device
|
||||||
|
// and orphaned for another.
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT d.id AS device_id, d.name AS device_name,
|
||||||
|
d.layout_id AS device_layout, dl.name AS device_layout_name,
|
||||||
|
pi.id AS item_id, pi.zone_id,
|
||||||
|
c.filename, c.mime_type,
|
||||||
|
lz.layout_id AS zone_layout, zl.name AS zone_layout_name, lz.name AS zone_name
|
||||||
|
FROM devices d
|
||||||
|
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
|
||||||
|
LEFT JOIN content c ON c.id = pi.content_id
|
||||||
|
LEFT JOIN layout_zones lz ON lz.id = pi.zone_id
|
||||||
|
LEFT JOIN layouts dl ON dl.id = d.layout_id
|
||||||
|
LEFT JOIN layouts zl ON zl.id = lz.layout_id
|
||||||
|
WHERE pi.zone_id IS NOT NULL
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
// Orphan = the item's zone doesn't exist any more, OR it belongs to a different
|
||||||
|
// layout than the device is actually rendering.
|
||||||
|
const orphans = rows.filter(r => !r.zone_layout || r.zone_layout !== r.device_layout);
|
||||||
|
|
||||||
|
if (!orphans.length) {
|
||||||
|
console.log(`No orphaned zone assignments found in ${dbPath}.`);
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${orphans.length} orphaned playlist_item(s) in ${dbPath}`);
|
||||||
|
console.log(`(zone_id references a zone that is NOT in the device's active layout):\n`);
|
||||||
|
for (const o of orphans) {
|
||||||
|
const sid = s => (s || '').slice(0, 8);
|
||||||
|
const where = o.zone_layout
|
||||||
|
? `zone "${o.zone_name}" lives in layout "${o.zone_layout_name}" (${sid(o.zone_layout)})`
|
||||||
|
: `zone_id no longer exists`;
|
||||||
|
console.log(` device "${o.device_name}" (${sid(o.device_id)}) active layout "${o.device_layout_name || '—'}" (${sid(o.device_layout)})`);
|
||||||
|
console.log(` item #${o.item_id} ${o.filename || '?'} [${o.mime_type || '?'}] zone_id=${sid(o.zone_id)} -> ${where}`);
|
||||||
|
}
|
||||||
|
console.log(`\nReport only — nothing changed. Un-patched players drop these; patched players`);
|
||||||
|
console.log(`route them to the largest zone and log a "zone" warning. Use the hardening`);
|
||||||
|
console.log(`(remap-on-duplicate / validate-on-assign) to stop new ones being created.`);
|
||||||
|
db.close();
|
||||||
|
|
@ -280,7 +280,7 @@ fi
|
||||||
if echo "\$KIOSK_URL" | grep -q "localhost"; then
|
if echo "\$KIOSK_URL" | grep -q "localhost"; then
|
||||||
echo "Waiting for ScreenTinker server..."
|
echo "Waiting for ScreenTinker server..."
|
||||||
for i in \$(seq 1 30); do
|
for i in \$(seq 1 30); do
|
||||||
if curl -sf "http://localhost:${SCREENTINKER_PORT}/api/health" >/dev/null 2>&1; then
|
if curl -sf "http://localhost:${SCREENTINKER_PORT}/api/status" >/dev/null 2>&1; then
|
||||||
echo "Server ready"
|
echo "Server ready"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
@ -288,8 +288,19 @@ if echo "\$KIOSK_URL" | grep -q "localhost"; then
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Detect screen resolution so Chromium fills the display on minimal X11 (no WM)
|
||||||
|
SCREEN_RES=\$(xrandr 2>/dev/null | grep ' connected' | grep -oE '[0-9]+x[0-9]+' | head -1)
|
||||||
|
SCREEN_W=\${SCREEN_RES%%x*}
|
||||||
|
SCREEN_H=\${SCREEN_RES##*x}
|
||||||
|
if [ -z "\$SCREEN_W" ] || [ -z "\$SCREEN_H" ]; then
|
||||||
|
SCREEN_W=1920
|
||||||
|
SCREEN_H=1080
|
||||||
|
fi
|
||||||
|
|
||||||
exec ${CHROMIUM_BIN} \\
|
exec ${CHROMIUM_BIN} \\
|
||||||
--kiosk \\
|
--kiosk \\
|
||||||
|
--window-position=0,0 \\
|
||||||
|
--window-size=\${SCREEN_W},\${SCREEN_H} \\
|
||||||
--noerrdialogs \\
|
--noerrdialogs \\
|
||||||
--disable-infobars \\
|
--disable-infobars \\
|
||||||
--disable-session-crashed-bubble \\
|
--disable-session-crashed-bubble \\
|
||||||
|
|
@ -298,7 +309,6 @@ exec ${CHROMIUM_BIN} \\
|
||||||
--check-for-update-interval=31536000 \\
|
--check-for-update-interval=31536000 \\
|
||||||
--autoplay-policy=no-user-gesture-required \\
|
--autoplay-policy=no-user-gesture-required \\
|
||||||
--no-first-run \\
|
--no-first-run \\
|
||||||
--start-fullscreen \\
|
|
||||||
--disable-pinch \\
|
--disable-pinch \\
|
||||||
--overscroll-history-navigation=0 \\
|
--overscroll-history-navigation=0 \\
|
||||||
--disable-translate \\
|
--disable-translate \\
|
||||||
|
|
|
||||||
|
|
@ -90,4 +90,63 @@ module.exports = {
|
||||||
// on MSP-style deployments where an admin/operator assigns users to existing
|
// on MSP-style deployments where an admin/operator assigns users to existing
|
||||||
// orgs after signup instead.
|
// orgs after signup instead.
|
||||||
autoCreateOrgOnSignup: !['false', '0'].includes(String(process.env.AUTO_CREATE_ORG_ON_SIGNUP || '').toLowerCase()),
|
autoCreateOrgOnSignup: !['false', '0'].includes(String(process.env.AUTO_CREATE_ORG_ON_SIGNUP || '').toLowerCase()),
|
||||||
|
|
||||||
|
// #142 event-loop lag telemetry (services/loop-lag.js). perf_hooks
|
||||||
|
// monitorEventLoopDelay is C++-backed, so continuous sampling is cheap. Each
|
||||||
|
// window's p99 is persisted to event_loop_lag (bounded: indexed + pruned from
|
||||||
|
// day one) and drives the banded load level the reconnect throttle reads.
|
||||||
|
lagSampleIntervalMs: parseInt(process.env.LAG_SAMPLE_INTERVAL_MS) || 1000,
|
||||||
|
lagResolutionMs: parseInt(process.env.LAG_RESOLUTION_MS) || 20,
|
||||||
|
lagTelemetryRetentionDays: parseFloat(process.env.LAG_TELEMETRY_RETENTION_DAYS) || 3,
|
||||||
|
lagPruneIntervalMs: parseInt(process.env.LAG_PRUNE_INTERVAL_MS) || 3600000,
|
||||||
|
// Banded load levels from the window p99 (ms). Asymmetric by design: a band is
|
||||||
|
// entered immediately when its up-threshold is crossed (tighten fast), but
|
||||||
|
// released only one step at a time after lagReleaseSamples consecutive samples
|
||||||
|
// fall below a deadband (release slow), so small fluctuations don't flap it.
|
||||||
|
// Bands ONLY scale how hard an already-flagged device is throttled; a healthy
|
||||||
|
// device is never gated by global lag.
|
||||||
|
lagElevatedMs: parseInt(process.env.LAG_ELEVATED_MS) || 100,
|
||||||
|
lagCriticalMs: parseInt(process.env.LAG_CRITICAL_MS) || 250,
|
||||||
|
lagReleaseSamples: parseInt(process.env.LAG_RELEASE_SAMPLES) || 5,
|
||||||
|
|
||||||
|
// #142 load-aware per-device reconnect throttle (lib/reconnect-throttle.js).
|
||||||
|
// The verdict of WHO is misbehaving is ALWAYS per-device (keyed on device_id):
|
||||||
|
// a device is flagged only when it exceeds reconnectBaseMax genuine reconnects
|
||||||
|
// per reconnectWindowMs. Global lag never flags a healthy device — the lag band
|
||||||
|
// only MULTIPLIES how hard an already-flagged device is backed off.
|
||||||
|
reconnectWindowMs: parseInt(process.env.RECONNECT_WINDOW_MS) || 10000,
|
||||||
|
reconnectBaseMax: parseInt(process.env.RECONNECT_BASE_MAX) || 5,
|
||||||
|
// Absolute per-device ceiling, independent of band AND of warm-up: no device may
|
||||||
|
// exceed this many reconnects/window no matter what the adaptive logic computes,
|
||||||
|
// so a slow-ramp attacker can't train its way through.
|
||||||
|
reconnectHardCeiling: parseInt(process.env.RECONNECT_HARD_CEILING) || 20,
|
||||||
|
// Server-enforced backoff for a flagged device: baseBackoff * 2^(level-1) * band
|
||||||
|
// multiplier, capped at maxBackoff. Level escalates while it keeps storming
|
||||||
|
// (tighten fast) and decays one step per reconnectReleaseMs of calm (release slow).
|
||||||
|
reconnectBaseBackoffMs: parseInt(process.env.RECONNECT_BASE_BACKOFF_MS) || 1000,
|
||||||
|
reconnectMaxBackoffMs: parseInt(process.env.RECONNECT_MAX_BACKOFF_MS) || 60000,
|
||||||
|
reconnectMaxLevel: parseInt(process.env.RECONNECT_MAX_LEVEL) || 10,
|
||||||
|
reconnectReleaseMs: parseInt(process.env.RECONNECT_RELEASE_MS) || 30000,
|
||||||
|
// Cold start: for this long after process start, lag is high while the whole
|
||||||
|
// fleet reconnects at once. Treat leniently — force the 'normal' band and apply
|
||||||
|
// only the hard ceiling (no rate-band throttle) so a deploy can't throttle
|
||||||
|
// healthy screens. Throttle state is in-memory and resets on restart.
|
||||||
|
reconnectWarmupMs: parseInt(process.env.RECONNECT_WARMUP_MS) || 30000,
|
||||||
|
reconnectBandElevatedMult: parseFloat(process.env.RECONNECT_BAND_ELEVATED_MULT) || 2,
|
||||||
|
reconnectBandCriticalMult: parseFloat(process.env.RECONNECT_BAND_CRITICAL_MULT) || 4,
|
||||||
|
|
||||||
|
// #142 device_status_log retention. A GLOBAL scheduled sweep (pruneStatusLog in
|
||||||
|
// db/database.js, run on startup + the heartbeat interval) deletes rows older
|
||||||
|
// than this across ALL devices — covering what the per-device insert-time prune
|
||||||
|
// in deviceSocket.js misses: removed/idle devices that never insert again, and
|
||||||
|
// the heartbeat.js offline_timeout insert that bypasses logDeviceStatus. Default
|
||||||
|
// is LOWER than the old hardcoded 7 days (the reporter's bloat happened under 7d);
|
||||||
|
// 2-3 days is plenty for the dashboard's 24h uptime view + diagnostics.
|
||||||
|
statusLogRetentionDays: parseFloat(process.env.STATUS_LOG_RETENTION_DAYS) || 3,
|
||||||
|
|
||||||
|
// #142 content-ack dedup window (deviceSocket.js). A device (esp. older apps)
|
||||||
|
// can spam "content <id>: ready" for the same item; suppress identical
|
||||||
|
// (device_id, content_id, status) reports within this window. A status CHANGE
|
||||||
|
// has a different key and passes immediately. In-memory; resets on restart.
|
||||||
|
contentAckDedupMs: parseInt(process.env.CONTENT_ACK_DEDUP_MS) || 10000,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,24 @@ const migrations = [
|
||||||
// signal, so the two differ — surfacing both explains "reports 720 but monitor sees 1080".
|
// signal, so the two differ — surfacing both explains "reports 720 but monitor sees 1080".
|
||||||
"ALTER TABLE devices ADD COLUMN render_width INTEGER",
|
"ALTER TABLE devices ADD COLUMN render_width INTEGER",
|
||||||
"ALTER TABLE devices ADD COLUMN render_height INTEGER",
|
"ALTER TABLE devices ADD COLUMN render_height INTEGER",
|
||||||
|
// #139 Phase 2: device-reported OTA backoff status, so the dashboard can flag screens that
|
||||||
|
// can't self-install (Fire TV: no device-owner path) and need a hands-on update. ADD COLUMN
|
||||||
|
// with defaults is non-destructive in SQLite, and the apply loop below swallows "duplicate
|
||||||
|
// column" — so this is idempotent and upgrades an existing populated db without data loss.
|
||||||
|
// ota_updated_at = server receipt time (s), stamped on each register persist.
|
||||||
|
"ALTER TABLE devices ADD COLUMN ota_status TEXT DEFAULT 'none'",
|
||||||
|
"ALTER TABLE devices ADD COLUMN ota_target_version TEXT",
|
||||||
|
"ALTER TABLE devices ADD COLUMN ota_attempts INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE devices ADD COLUMN ota_updated_at INTEGER",
|
||||||
|
// #142: index device_status_log for the per-device + time-window access pattern.
|
||||||
|
// schema.sql creates this on fresh installs; this migration covers existing DBs.
|
||||||
|
// Both the dashboard uptime query and the retention prune were full scans — the
|
||||||
|
// dashboard-degradation cause once the table reached 1M+ rows.
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_device_status_log_device_ts ON device_status_log(device_id, timestamp)",
|
||||||
|
// #142: event-loop lag telemetry table (bounded: indexed + scheduled prune).
|
||||||
|
// schema.sql creates these on fresh installs; this covers existing DBs.
|
||||||
|
"CREATE TABLE IF NOT EXISTS event_loop_lag (id INTEGER PRIMARY KEY AUTOINCREMENT, sampled_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), mean_ms REAL NOT NULL, p50_ms REAL NOT NULL, p99_ms REAL NOT NULL, max_ms REAL NOT NULL, band TEXT NOT NULL DEFAULT 'normal')",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_event_loop_lag_sampled ON event_loop_lag(sampled_at)",
|
||||||
];
|
];
|
||||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||||
// error means the column is already present (expected on a migrated DB) - benign.
|
// error means the column is already present (expected on a migrated DB) - benign.
|
||||||
|
|
@ -732,6 +750,21 @@ const { applyTenantDeleteCascade } = require('../lib/tenant-cascade-migration');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// #142 GLOBAL device_status_log retention sweep across ALL devices. Run on startup
|
||||||
|
// and on the heartbeat interval (services/heartbeat.js). This covers the rows the
|
||||||
|
// per-device insert-time prune in deviceSocket.js misses: removed/idle devices that
|
||||||
|
// never insert again, and the heartbeat offline_timeout insert that bypasses
|
||||||
|
// logDeviceStatus. A plain time-range delete (like the play_logs prune) — runs off
|
||||||
|
// the hot path; after the first sweep the table is small, so the cost is negligible.
|
||||||
|
function pruneStatusLog() {
|
||||||
|
try {
|
||||||
|
const maxAgeSec = Math.round(config.statusLogRetentionDays * 86400);
|
||||||
|
const n = db.prepare("DELETE FROM device_status_log WHERE timestamp < strftime('%s','now') - ?").run(maxAgeSec).changes;
|
||||||
|
if (n > 0) console.log(`[status-log] pruned ${n} row(s) older than ${config.statusLogRetentionDays}d`);
|
||||||
|
return n;
|
||||||
|
} catch (_) { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
|
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
|
||||||
function pruneTelemetry(deviceId) {
|
function pruneTelemetry(deviceId) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
|
|
@ -804,4 +837,4 @@ try {
|
||||||
const { verifyAndRepairSchema } = require('../lib/schema-check');
|
const { verifyAndRepairSchema } = require('../lib/schema-check');
|
||||||
verifyAndRepairSchema(db);
|
verifyAndRepairSchema(db);
|
||||||
|
|
||||||
module.exports = { db, pruneTelemetry, pruneScreenshots };
|
module.exports = { db, pruneTelemetry, pruneScreenshots, pruneStatusLog };
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,27 @@ CREATE TABLE IF NOT EXISTS device_status_log (
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
);
|
);
|
||||||
|
-- #142: index the per-device + time-window access pattern. Both the dashboard
|
||||||
|
-- uptime query (WHERE device_id=? AND timestamp>?) and the retention prune
|
||||||
|
-- (WHERE device_id=? AND timestamp<?) were full table scans; at 1M+ rows that
|
||||||
|
-- was the dashboard-degradation cause in the outage report.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_status_log_device_ts ON device_status_log(device_id, timestamp);
|
||||||
|
|
||||||
|
-- ===================== EVENT LOOP LAG (#142) =====================
|
||||||
|
-- Event-loop delay telemetry from perf_hooks.monitorEventLoopDelay(). Bounded
|
||||||
|
-- from day one: indexed on sampled_at and pruned on a schedule (see
|
||||||
|
-- services/loop-lag.js, LAG_TELEMETRY_RETENTION_DAYS) so it can never become a
|
||||||
|
-- second unbounded-growth table.
|
||||||
|
CREATE TABLE IF NOT EXISTS event_loop_lag (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sampled_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
mean_ms REAL NOT NULL,
|
||||||
|
p50_ms REAL NOT NULL,
|
||||||
|
p99_ms REAL NOT NULL,
|
||||||
|
max_ms REAL NOT NULL,
|
||||||
|
band TEXT NOT NULL DEFAULT 'normal'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_loop_lag_sampled ON event_loop_lag(sampled_at);
|
||||||
|
|
||||||
-- ===================== DEVICE FINGERPRINTS =====================
|
-- ===================== DEVICE FINGERPRINTS =====================
|
||||||
|
|
||||||
|
|
@ -484,13 +505,6 @@ CREATE TABLE IF NOT EXISTS alert_configs (
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS device_status_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
device_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ===================== PLAYER DEBUG LOGS =====================
|
-- ===================== PLAYER DEBUG LOGS =====================
|
||||||
-- Smart TVs (Tizen, WebOS, Fire TV, etc.) have no accessible devtools. The
|
-- Smart TVs (Tizen, WebOS, Fire TV, etc.) have no accessible devtools. The
|
||||||
-- player captures errors into window.__debugLog client-side and POSTs them
|
-- player captures errors into window.__debugLog client-side and POSTs them
|
||||||
|
|
|
||||||
98
server/lib/reconnect-throttle.js
Normal file
98
server/lib/reconnect-throttle.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// #142 step 3 — load-aware per-device reconnect throttle (the outage fix).
|
||||||
|
//
|
||||||
|
// A single device stuck in a tight websocket reconnect loop can flood the server
|
||||||
|
// with full register cycles (DB writes + playlist build) and saturate the event
|
||||||
|
// loop. This module gates genuine reconnects PER DEVICE, before that heavy work
|
||||||
|
// runs in deviceSocket.js.
|
||||||
|
//
|
||||||
|
// Design (mirrors the issue's suggested mitigation + the lastPlayLogAt pattern):
|
||||||
|
// - WHO is always per-device: a device is "flagged" only when it exceeds
|
||||||
|
// reconnectBaseMax genuine reconnects within reconnectWindowMs. Global lag
|
||||||
|
// NEVER flags a healthy device.
|
||||||
|
// - Load-awareness is BANDED (normal/elevated/critical from services/loop-lag),
|
||||||
|
// not a continuous controller — deterministic and testable. The band only
|
||||||
|
// MULTIPLIES the backoff applied to an ALREADY-flagged device.
|
||||||
|
// - Hysteresis: escalate immediately while storming (tighten fast); decay the
|
||||||
|
// escalation level one step per reconnectReleaseMs of calm (release slow).
|
||||||
|
// - HARD CEILING: independent of band and of warm-up, no device may exceed
|
||||||
|
// reconnectHardCeiling/window — a slow-ramp attacker can't train through it.
|
||||||
|
// - COLD START: for reconnectWarmupMs after process start, force the 'normal'
|
||||||
|
// band and apply only the hard ceiling, so a full-fleet reconnect right after
|
||||||
|
// a deploy doesn't throttle healthy screens.
|
||||||
|
// - State is in-memory (resets on restart), like pair-lockout / totp-lockout.
|
||||||
|
|
||||||
|
const config = require('../config');
|
||||||
|
const loopLag = require('../services/loop-lag');
|
||||||
|
|
||||||
|
// deviceId -> { hits: number[], level: number, blockedUntil: ms, lastThrottleAt: ms }
|
||||||
|
const state = new Map();
|
||||||
|
let startedAt = Date.now();
|
||||||
|
|
||||||
|
function bandMultiplier(band) {
|
||||||
|
if (band === 'critical') return config.reconnectBandCriticalMult;
|
||||||
|
if (band === 'elevated') return config.reconnectBandElevatedMult;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(s, now, band, reason, observed, allowed) {
|
||||||
|
s.level = Math.min(s.level + 1, config.reconnectMaxLevel);
|
||||||
|
const backoff = Math.min(
|
||||||
|
config.reconnectBaseBackoffMs * Math.pow(2, s.level - 1) * bandMultiplier(band),
|
||||||
|
config.reconnectMaxBackoffMs
|
||||||
|
);
|
||||||
|
s.blockedUntil = now + backoff;
|
||||||
|
s.lastThrottleAt = now;
|
||||||
|
return { allow: false, retryAfterMs: backoff, reason, observed, allowed, band, level: s.level };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide whether to allow a genuine reconnect for `deviceId`.
|
||||||
|
// `now` and `bandOverride` are injectable for deterministic tests; production
|
||||||
|
// passes only deviceId.
|
||||||
|
function check(deviceId, now = Date.now(), bandOverride = null) {
|
||||||
|
const warmup = (now - startedAt) < config.reconnectWarmupMs;
|
||||||
|
const band = bandOverride !== null ? bandOverride : (warmup ? 'normal' : loopLag.getBand());
|
||||||
|
|
||||||
|
let s = state.get(deviceId);
|
||||||
|
if (!s) { s = { hits: [], level: 0, blockedUntil: 0, lastThrottleAt: 0 }; state.set(deviceId, s); }
|
||||||
|
|
||||||
|
// Already inside an enforced backoff window: reject and escalate (tighten fast).
|
||||||
|
if (now < s.blockedUntil) {
|
||||||
|
return reject(s, now, band, 'in-backoff', s.hits.length, config.reconnectBaseMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sliding window of genuine reconnects.
|
||||||
|
s.hits = s.hits.filter((t) => now - t < config.reconnectWindowMs);
|
||||||
|
s.hits.push(now);
|
||||||
|
const observed = s.hits.length;
|
||||||
|
|
||||||
|
// Hard ceiling — always enforced, regardless of band or warm-up.
|
||||||
|
if (observed > config.reconnectHardCeiling) {
|
||||||
|
return reject(s, now, band, 'hard-ceiling', observed, config.reconnectHardCeiling);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold start: only the hard ceiling applies; never rate-throttle during warm-up.
|
||||||
|
if (warmup) return allow(s, now, band);
|
||||||
|
|
||||||
|
// Healthy device: under the per-device threshold -> always allowed.
|
||||||
|
if (observed <= config.reconnectBaseMax) return allow(s, now, band);
|
||||||
|
|
||||||
|
// Flagged: storming beyond the per-device threshold -> throttle (band-scaled).
|
||||||
|
return reject(s, now, band, 'rate', observed, config.reconnectBaseMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allow(s, now, band) {
|
||||||
|
// Release slow: decay one escalation level per reconnectReleaseMs of calm.
|
||||||
|
if (s.level > 0 && now - s.lastThrottleAt > config.reconnectReleaseMs) {
|
||||||
|
s.level = Math.max(0, s.level - 1);
|
||||||
|
s.lastThrottleAt = now;
|
||||||
|
}
|
||||||
|
return { allow: true, band, level: s.level };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only: clear state and optionally rewind the warm-up origin.
|
||||||
|
function __resetForTest(opts = {}) {
|
||||||
|
state.clear();
|
||||||
|
if (opts.startedAt !== undefined) startedAt = opts.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { check, __resetForTest };
|
||||||
51
server/lib/zone-validate.js
Normal file
51
server/lib/zone-validate.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
const { db } = require('../db/database');
|
||||||
|
|
||||||
|
// Single source of truth for the "orphaned zone" definition used across the server:
|
||||||
|
// assignment validation (routes/assignments.js validZoneForLayout), the device payload
|
||||||
|
// orphan flags/counts (routes/devices.js), and — by the SAME rule, mirrored in their own
|
||||||
|
// languages — the player fallback (server/player/index.html, ZoneManager.kt) and the
|
||||||
|
// find-orphan-zone-items.js sweep.
|
||||||
|
//
|
||||||
|
// Rule: an item's zone_id is VALID only if it is a zone in the device's ACTIVE layout.
|
||||||
|
// A null/empty zone_id is "unassigned" (not an orphan). A zone_id on a device with no
|
||||||
|
// active layout can never be valid -> orphan.
|
||||||
|
|
||||||
|
/** True if zoneId belongs to layoutId (or zoneId is empty = unassigned). */
|
||||||
|
function zoneInLayout(zoneId, layoutId) {
|
||||||
|
if (!zoneId) return true;
|
||||||
|
if (!layoutId) return false;
|
||||||
|
return !!db.prepare('SELECT 1 FROM layout_zones WHERE id = ? AND layout_id = ?').get(zoneId, layoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when zoneId is set but NOT a zone in the device's active layout. */
|
||||||
|
function isOrphanZone(zoneId, layoutId) {
|
||||||
|
return !!zoneId && !zoneInLayout(zoneId, layoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zones (id+name) of a layout, for populating reassign dropdowns. [] if none. */
|
||||||
|
function layoutZones(layoutId) {
|
||||||
|
if (!layoutId) return [];
|
||||||
|
return db.prepare('SELECT id, name FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk: map of device_id -> count of its playlist_items whose zone_id is NOT in the
|
||||||
|
* device's active layout. Same rule as isOrphanZone, computed in one query for the
|
||||||
|
* dashboard device list. Devices with zero orphans are omitted from the map.
|
||||||
|
*/
|
||||||
|
function orphanCountsByDevice(deviceIds) {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT d.id AS device_id, COUNT(*) AS n
|
||||||
|
FROM devices d
|
||||||
|
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
|
||||||
|
LEFT JOIN layout_zones lz ON lz.id = pi.zone_id AND lz.layout_id = d.layout_id
|
||||||
|
WHERE pi.zone_id IS NOT NULL AND lz.id IS NULL
|
||||||
|
GROUP BY d.id
|
||||||
|
`).all();
|
||||||
|
const map = {};
|
||||||
|
const want = deviceIds && deviceIds.length ? new Set(deviceIds) : null;
|
||||||
|
for (const r of rows) { if (!want || want.has(r.device_id)) map[r.device_id] = r.n; }
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { zoneInLayout, isOrphanZone, layoutZones, orphanCountsByDevice };
|
||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "screentinker",
|
"name": "screentinker",
|
||||||
"version": "1.9.1-beta5",
|
"version": "1.9.2-beta1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "screentinker",
|
"name": "screentinker",
|
||||||
"version": "1.9.1-beta5",
|
"version": "1.9.2-beta1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-node": "^5.2.1",
|
"@azure/msal-node": "^5.2.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "screentinker",
|
"name": "screentinker",
|
||||||
"version": "1.9.1-beta5",
|
"version": "1.9.2-beta1",
|
||||||
"description": "ScreenTinker - Digital Signage Management Server",
|
"description": "ScreenTinker - Digital Signage Management Server",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,11 @@
|
||||||
// Per-zone rotation timers (multi-zone). Each zone advances independently on
|
// Per-zone rotation timers (multi-zone). Each zone advances independently on
|
||||||
// its own interval, decoupled from the fullscreen advanceTimer/nextItem.
|
// its own interval, decoupled from the fullscreen advanceTimer/nextItem.
|
||||||
let zoneTimers = {};
|
let zoneTimers = {};
|
||||||
|
// #zone-orphan: parts of the operator-only PREVIEW banner (never shown on a live
|
||||||
|
// player). 'layout' = dominant-layout note when items span >1 layout; 'orphans' =
|
||||||
|
// the list of items whose zone isn't in the active layout. renderPreviewBanner()
|
||||||
|
// composes them into a single #previewBanner element.
|
||||||
|
let previewBannerParts = {};
|
||||||
// Video wall state. wallConfig is the tile assignment from the server
|
// Video wall state. wallConfig is the tile assignment from the server
|
||||||
// (null when this device isn't in a wall). The leader runs the playlist
|
// (null when this device isn't in a wall). The leader runs the playlist
|
||||||
// normally and broadcasts wall:sync every second; followers don't run
|
// normally and broadcasts wall:sync every second; followers don't run
|
||||||
|
|
@ -690,11 +695,9 @@
|
||||||
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
|
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
|
||||||
// Device payloads never carry this flag (layout is device-bound, unambiguous).
|
// Device payloads never carry this flag (layout is device-bound, unambiguous).
|
||||||
if (payload.layout && payload.layout._preview_ambiguous) {
|
if (payload.layout && payload.layout._preview_ambiguous) {
|
||||||
const b = document.createElement('div');
|
previewBannerParts.layout = 'Previewing dominant layout "' + (payload.layout.name || '—') + '" — items span more than one layout';
|
||||||
b.textContent = 'Previewing layout: ' + (payload.layout.name || '—');
|
|
||||||
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.92);color:#000;font:12px sans-serif;padding:4px 10px;text-align:center';
|
|
||||||
document.body.appendChild(b);
|
|
||||||
}
|
}
|
||||||
|
renderPreviewBanner();
|
||||||
handlePlaylistUpdate(payload);
|
handlePlaylistUpdate(payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('preview fetch failed', e);
|
console.error('preview fetch failed', e);
|
||||||
|
|
@ -702,6 +705,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #zone-orphan: one operator-only banner, built from previewBannerParts. Created on
|
||||||
|
// first use, removed when there's nothing to report. PREVIEW_MODE only — the live
|
||||||
|
// player/wall must stay clean for the audience.
|
||||||
|
function renderPreviewBanner() {
|
||||||
|
if (!PREVIEW_MODE) return;
|
||||||
|
const lines = [previewBannerParts.layout, previewBannerParts.orphans].filter(Boolean);
|
||||||
|
let el = document.getElementById('previewBanner');
|
||||||
|
if (!lines.length) { if (el) el.remove(); return; }
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = 'previewBanner';
|
||||||
|
el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.94);color:#000;font:12px sans-serif;padding:5px 10px;text-align:center;line-height:1.4';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
el.innerHTML = lines.join('<br>');
|
||||||
|
}
|
||||||
|
|
||||||
function showPreviewError(status) {
|
function showPreviewError(status) {
|
||||||
const msg = (status === 401 || status === 403) ? 'Not authorized to preview this playlist'
|
const msg = (status === 401 || status === 403) ? 'Not authorized to preview this playlist'
|
||||||
: status ? ('Preview failed (' + status + ')') : 'Preview failed to load';
|
: status ? ('Preview failed (' + status + ')') : 'Preview failed to load';
|
||||||
|
|
@ -963,6 +983,13 @@
|
||||||
function pipReport(level, msg) {
|
function pipReport(level, msg) {
|
||||||
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
|
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
// Zone diagnostics (orphaned zone_id fallback). Mirrors pipReport: stream to the
|
||||||
|
// dashboard device-log (tag 'zone') AND the in-page debug overlay buffer.
|
||||||
|
function zoneReport(level, msg) {
|
||||||
|
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'zone', level, message: msg }); } catch (e) {}
|
||||||
|
try { window.__debugLog_push && window.__debugLog_push({ type: 'zone', level: level, msg: msg }); } catch (e) {}
|
||||||
|
try { console.warn('[zone] ' + msg); } catch (e) {}
|
||||||
|
}
|
||||||
function pipTeardown() {
|
function pipTeardown() {
|
||||||
try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
|
try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
|
||||||
pipTimer = null; pipCurrent = null;
|
pipTimer = null; pipCurrent = null;
|
||||||
|
|
@ -1741,13 +1768,42 @@
|
||||||
// Group assignments by zone, ordered by sort_order so each zone rotates its
|
// Group assignments by zone, ordered by sort_order so each zone rotates its
|
||||||
// OWN list independently (images/widgets on a duration timer, videos on end)
|
// OWN list independently (images/widgets on a duration timer, videos on end)
|
||||||
// rather than every zone re-rendering on a single global tick.
|
// rather than every zone re-rendering on a single global tick.
|
||||||
|
// Zone-orphan fallback: an item whose zone_id is NOT a zone in the active layout
|
||||||
|
// (assigned under a different layout, or the layout was duplicated/switched — the new
|
||||||
|
// zones get fresh ids) would otherwise be SILENTLY DROPPED, because its bucket never
|
||||||
|
// matches a rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it
|
||||||
|
// shares screen time there (one item at a time -> never overlays/stacks on existing
|
||||||
|
// content) and emit telemetry so the stale assignment is diagnosable. (See the
|
||||||
|
// fallback-rule rationale in the change notes.)
|
||||||
|
const validZoneIds = new Set(layout.zones.map(z => z.id));
|
||||||
|
const fallbackZone = layout.zones.reduce(
|
||||||
|
(a, b) => (((b.width_percent || 0) * (b.height_percent || 0)) > ((a.width_percent || 0) * (a.height_percent || 0)) ? b : a),
|
||||||
|
layout.zones[0]);
|
||||||
const byZone = {};
|
const byZone = {};
|
||||||
|
const orphanNames = [];
|
||||||
for (const a of playlist) {
|
for (const a of playlist) {
|
||||||
const zid = a.zone_id || '__none__';
|
let zid = a.zone_id || '__none__';
|
||||||
|
if (a.zone_id && !validZoneIds.has(a.zone_id) && fallbackZone) {
|
||||||
|
zoneReport('warn', 'orphan zone_id=' + a.zone_id + ' item=' + (a.filename || a.content_id || a.widget_id || '?') +
|
||||||
|
' device=' + (config.deviceId || 'preview') + ' -> fallback zone "' + (fallbackZone.name || fallbackZone.id) + '"');
|
||||||
|
orphanNames.push(a.filename || a.content_id || a.widget_id || '?');
|
||||||
|
zid = fallbackZone.id;
|
||||||
|
}
|
||||||
(byZone[zid] = byZone[zid] || []).push(a);
|
(byZone[zid] = byZone[zid] || []).push(a);
|
||||||
}
|
}
|
||||||
for (const k in byZone) byZone[k].sort((x, y) => (x.sort_order || 0) - (y.sort_order || 0));
|
for (const k in byZone) byZone[k].sort((x, y) => (x.sort_order || 0) - (y.sort_order || 0));
|
||||||
|
|
||||||
|
// #zone-orphan: operator-only preview note naming the stale items (NOT on a live
|
||||||
|
// player — zoneReport already streams those to the dashboard device-log instead).
|
||||||
|
if (PREVIEW_MODE) {
|
||||||
|
previewBannerParts.orphans = orphanNames.length
|
||||||
|
? orphanNames.length + ' item(s) assigned to a different layout: ' +
|
||||||
|
orphanNames.slice(0, 6).join(', ') + (orphanNames.length > 6 ? '…' : '') +
|
||||||
|
' — showing in "' + ((fallbackZone && (fallbackZone.name || fallbackZone.id)) || '—') + '"'
|
||||||
|
: null;
|
||||||
|
renderPreviewBanner();
|
||||||
|
}
|
||||||
|
|
||||||
let unassignedUsed = false;
|
let unassignedUsed = false;
|
||||||
layout.zones.forEach(zone => {
|
layout.zones.forEach(zone => {
|
||||||
let items = byZone[zone.id];
|
let items = byZone[zone.id];
|
||||||
|
|
@ -1824,69 +1880,168 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Screenshots ====================
|
// ==================== Screenshots ====================
|
||||||
function captureAndSend() {
|
// Draw a media element into a destination rect honouring its object-fit, so the capture
|
||||||
if (!socket?.connected) return;
|
// matches what's on screen instead of stretching the source to a fixed size (the old
|
||||||
|
// bug). 'cover' crops the source; 'contain' letterboxes; 'fill' stretches.
|
||||||
|
function drawMediaFit(ctx, el, ew, eh, dx, dy, dw, dh, fit) {
|
||||||
|
if (!ew || !eh) { ctx.drawImage(el, dx, dy, dw, dh); return; }
|
||||||
|
if (fit === 'fill') { ctx.drawImage(el, dx, dy, dw, dh); return; }
|
||||||
|
const er = ew / eh, dr = dw / dh;
|
||||||
|
if (fit === 'contain') {
|
||||||
|
let w = dw, h = dh;
|
||||||
|
if (er > dr) h = dw / er; else w = dh * er;
|
||||||
|
ctx.drawImage(el, dx + (dw - w) / 2, dy + (dh - h) / 2, w, h);
|
||||||
|
} else { // 'cover' (zone default) and anything unexpected -> crop to fill, no distortion
|
||||||
|
let sw = ew, sh = eh, sx = 0, sy = 0;
|
||||||
|
if (er > dr) { sw = eh * dr; sx = (ew - sw) / 2; }
|
||||||
|
else { sh = ew / dr; sy = (eh - sh) / 2; }
|
||||||
|
ctx.drawImage(el, sx, sy, sw, sh, dx, dy, dw, dh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cross-origin <img>/<video> drawn onto the canvas without CORS taints the WHOLE
|
||||||
|
// canvas, making toDataURL() throw and killing the entire capture. Only same-origin
|
||||||
|
// media (served by us) or media explicitly loaded with crossOrigin is safe to read back.
|
||||||
|
function isMediaReadable(el) {
|
||||||
|
const url = el.currentSrc || el.src || '';
|
||||||
|
if (!url) return false;
|
||||||
|
if (el.crossOrigin) return true;
|
||||||
|
try { return new URL(url, location.href).origin === location.origin; }
|
||||||
|
catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function zonePlaceholderLabel(el) {
|
||||||
|
if (!el) return 'Live';
|
||||||
|
if (el.tagName === 'IFRAME') {
|
||||||
|
const s = el.src || '';
|
||||||
|
if (/youtube|ytimg|youtu\.be/i.test(s)) return 'YouTube';
|
||||||
|
if (/\/widgets\//i.test(s)) return 'Widget';
|
||||||
|
return 'Web';
|
||||||
|
}
|
||||||
|
if (el.tagName === 'VIDEO') return 'Video';
|
||||||
|
return 'Live';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberate, labelled placeholder for a zone we can't read back (cross-origin iframe
|
||||||
|
// like YouTube/widgets, or cross-origin media). The shot still shows the layout
|
||||||
|
// structure with this zone clearly marked — never a transparent hole.
|
||||||
|
function drawZonePlaceholder(ctx, dx, dy, dw, dh, label) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#1f2937';
|
||||||
|
ctx.fillRect(dx, dy, dw, dh);
|
||||||
|
ctx.strokeStyle = 'rgba(148,163,184,0.35)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(dx + 0.5, dy + 0.5, Math.max(0, dw - 1), Math.max(0, dh - 1));
|
||||||
|
if (label) {
|
||||||
|
ctx.fillStyle = '#cbd5e1';
|
||||||
|
const fs = Math.max(11, Math.min(22, Math.round(dh * 0.16)));
|
||||||
|
ctx.font = `600 ${fs}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(label, dx + dw / 2, dy + dh / 2);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composite a multi-zone layout zone-by-zone. Each zone's destination rect is derived
|
||||||
|
// from its REAL rendered geometry (getBoundingClientRect relative to the container) and
|
||||||
|
// scaled proportionally onto the canvas — so positions/sizes stay true to the layout
|
||||||
|
// rather than one element stretched across the frame.
|
||||||
|
function drawZoneComposite(ctx, container, cr, W, H) {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
const zones = container.querySelectorAll('.zone');
|
||||||
|
if (!zones.length) return false;
|
||||||
|
zones.forEach((zd) => {
|
||||||
|
const r = zd.getBoundingClientRect();
|
||||||
|
const dx = ((r.left - cr.left) / cr.width) * W;
|
||||||
|
const dy = ((r.top - cr.top) / cr.height) * H;
|
||||||
|
const dw = (r.width / cr.width) * W;
|
||||||
|
const dh = (r.height / cr.height) * H;
|
||||||
|
const el = zd.querySelector('video, img, iframe');
|
||||||
|
let drawn = false;
|
||||||
|
if (el && el.tagName === 'IMG' && el.complete && el.naturalWidth > 0 && isMediaReadable(el)) {
|
||||||
|
try { drawMediaFit(ctx, el, el.naturalWidth, el.naturalHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {}
|
||||||
|
} else if (el && el.tagName === 'VIDEO' && el.readyState >= 2 && el.videoWidth > 0 && isMediaReadable(el)) {
|
||||||
|
try { drawMediaFit(ctx, el, el.videoWidth, el.videoHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {}
|
||||||
|
}
|
||||||
|
if (!drawn) drawZonePlaceholder(ctx, dx, dy, dw, dh, zonePlaceholderLabel(el));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the screenshot/stream canvas and return it (caller encodes + sends). Exposed as
|
||||||
|
// a plain function so a headless render pass can verify the composite without a socket.
|
||||||
|
function renderCaptureCanvas() {
|
||||||
|
const container = document.getElementById('playerContainer');
|
||||||
|
const cr = container ? container.getBoundingClientRect() : null;
|
||||||
|
// ~960 on the long edge; height from the REAL container aspect, not a hardcoded 540,
|
||||||
|
// so non-16:9 layouts compose without distortion.
|
||||||
|
const aspect = (cr && cr.width > 0 && cr.height > 0) ? (cr.width / cr.height) : (16 / 9);
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 960;
|
canvas.width = 960;
|
||||||
canvas.height = 540;
|
canvas.height = Math.max(1, Math.round(960 / aspect));
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
let captured = false;
|
let captured = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const container = document.getElementById('playerContainer');
|
const multiZone = !!(layout && Array.isArray(layout.zones) && layout.zones.length > 1 && !wallConfig);
|
||||||
const video = container?.querySelector('video');
|
if (multiZone && container) {
|
||||||
const img = container?.querySelector('img');
|
captured = drawZoneComposite(ctx, container, cr, W, H);
|
||||||
|
} else if (container) {
|
||||||
// Try video first
|
// Single-zone / fullscreen fast path: one element, drawn with its own object-fit
|
||||||
if (video && video.readyState >= 2 && video.videoWidth > 0) {
|
// (the old code stretched it to a fixed 960x540).
|
||||||
try {
|
const video = container.querySelector('video');
|
||||||
ctx.drawImage(video, 0, 0, 960, 540);
|
const img = container.querySelector('img');
|
||||||
captured = true;
|
if (video && video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video)) {
|
||||||
} catch (e) {
|
try { drawMediaFit(ctx, video, video.videoWidth, video.videoHeight, 0, 0, W, H, getComputedStyle(video).objectFit); captured = true; } catch (e) { console.warn('Video capture failed (CORS?):', e.message); }
|
||||||
console.warn('Video capture failed (CORS?):', e.message);
|
|
||||||
}
|
}
|
||||||
}
|
if (!captured && img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) {
|
||||||
|
try { drawMediaFit(ctx, img, img.naturalWidth, img.naturalHeight, 0, 0, W, H, getComputedStyle(img).objectFit); captured = true; } catch (e) { console.warn('Image capture failed:', e.message); }
|
||||||
// Try image
|
|
||||||
if (!captured && img && img.complete && img.naturalWidth > 0) {
|
|
||||||
try {
|
|
||||||
ctx.drawImage(img, 0, 0, 960, 540);
|
|
||||||
captured = true;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Image capture failed:', e.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: draw status info
|
// Fallback: draw status info
|
||||||
if (!captured) {
|
if (!captured) {
|
||||||
ctx.fillStyle = '#111827';
|
ctx.fillStyle = '#111827';
|
||||||
ctx.fillRect(0, 0, 960, 540);
|
ctx.fillRect(0, 0, W, H);
|
||||||
ctx.fillStyle = '#3b82f6';
|
ctx.fillStyle = '#3b82f6';
|
||||||
ctx.font = 'bold 28px sans-serif';
|
ctx.font = 'bold 28px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText('ScreenTinker Web Player', 480, 230);
|
ctx.fillText('ScreenTinker Web Player', W / 2, H / 2 - 40);
|
||||||
ctx.fillStyle = '#94a3b8';
|
ctx.fillStyle = '#94a3b8';
|
||||||
ctx.font = '16px sans-serif';
|
ctx.font = '16px sans-serif';
|
||||||
const item = playlist[currentIndex];
|
const item = playlist[currentIndex];
|
||||||
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', 480, 270);
|
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', W / 2, H / 2);
|
||||||
ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, 480, 310);
|
ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, W / 2, H / 2 + 40);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Even on error, draw something
|
// Even on error, draw something
|
||||||
ctx.fillStyle = '#000';
|
ctx.fillStyle = '#000';
|
||||||
ctx.fillRect(0, 0, 960, 540);
|
ctx.fillRect(0, 0, W, H);
|
||||||
ctx.fillStyle = '#ef4444';
|
ctx.fillStyle = '#ef4444';
|
||||||
ctx.font = '16px sans-serif';
|
ctx.font = '16px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText('Screenshot error: ' + e.message, 480, 270);
|
ctx.fillText('Screenshot error: ' + e.message, W / 2, H / 2);
|
||||||
}
|
}
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureAndSend() {
|
||||||
|
if (!socket?.connected) return;
|
||||||
|
// Also drives the 1fps remote stream (startStreaming). The composite is just a handful
|
||||||
|
// of drawImage calls over already-decoded media, so one full-quality path serves both
|
||||||
|
// the on-demand screenshot and the 1fps stream — no separate low-quality stream path.
|
||||||
|
let canvas;
|
||||||
|
try { canvas = renderCaptureCanvas(); }
|
||||||
|
catch (e) { console.error('Screenshot render failed:', e); return; }
|
||||||
try {
|
try {
|
||||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.4);
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.4);
|
||||||
const base64 = dataUrl.split(',')[1];
|
const base64 = dataUrl.split(',')[1];
|
||||||
if (base64 && base64.length > 100) {
|
if (base64 && base64.length > 100) {
|
||||||
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
|
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
|
||||||
console.log('Screenshot sent:', base64.length, 'chars', captured ? '(content)' : '(fallback)');
|
console.log('Screenshot sent:', base64.length, 'chars');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Screenshot encode/send failed:', e);
|
console.error('Screenshot encode/send failed:', e);
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,29 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
||||||
// already carry workspace_id from Phase 1; this route can use them even
|
// already carry workspace_id from Phase 1; this route can use them even
|
||||||
// though playlists.js itself isn't yet workspace-filtered.
|
// though playlists.js itself isn't yet workspace-filtered.
|
||||||
const { accessContext } = require('../lib/tenancy');
|
const { accessContext } = require('../lib/tenancy');
|
||||||
|
const { zoneInLayout } = require('../lib/zone-validate');
|
||||||
|
|
||||||
// Mark playlist as draft (called after any item mutation)
|
// Mark playlist as draft (called after any item mutation)
|
||||||
function markDraft(playlistId) {
|
function markDraft(playlistId) {
|
||||||
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
|
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hardening (#zone-orphan): a zone_id only renders if it belongs to the layout the
|
||||||
|
// device is actually showing. Assigning a zone from a DIFFERENT layout (e.g. after a
|
||||||
|
// layout switch/duplicate) creates an item that the players can't place. We CLEAR a
|
||||||
|
// stale zone_id to null here (-> "unassigned", which the players route sensibly) rather
|
||||||
|
// than reject, so this can't break a caller; the cleared write is logged. NOTE for
|
||||||
|
// review: switch to a 400 reject if you'd rather surface the bad zone to the operator.
|
||||||
|
// Returns the zone_id to persist (the given one, or null if it isn't in the device's
|
||||||
|
// active layout). deviceLayoutId may be null (device on fullscreen) -> any zone_id is
|
||||||
|
// stale, so cleared.
|
||||||
|
function validZoneForLayout(zoneId, deviceLayoutId, ctx) {
|
||||||
|
if (!zoneId) return null;
|
||||||
|
if (zoneInLayout(zoneId, deviceLayoutId)) return zoneId;
|
||||||
|
console.warn(`[assign] cleared stale zone_id ${zoneId} (not in active layout ${deviceLayoutId || 'none'})${ctx ? ' ' + ctx : ''}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2.2j: workspace-aware device access check. Returns access context
|
// Phase 2.2j: workspace-aware device access check. Returns access context
|
||||||
// (with workspaceRole/actingAs) or null. Caller decides if read or write.
|
// (with workspaceRole/actingAs) or null. Caller decides if read or write.
|
||||||
function checkDeviceAccess(req, res, paramName = 'deviceId', requireWrite = true) {
|
function checkDeviceAccess(req, res, paramName = 'deviceId', requireWrite = true) {
|
||||||
|
|
@ -99,6 +116,10 @@ router.post('/device/:deviceId', (req, res) => {
|
||||||
|
|
||||||
const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id);
|
const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id);
|
||||||
|
|
||||||
|
// Hardening: clear a zone_id that isn't in THIS device's active layout (prevents new orphans).
|
||||||
|
const devLayout = db.prepare('SELECT layout_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
||||||
|
const effZone = validZoneForLayout(zone_id, devLayout?.layout_id, `on add to device ${req.params.deviceId}`);
|
||||||
|
|
||||||
let order = sort_order;
|
let order = sort_order;
|
||||||
if (order === undefined || order === null) {
|
if (order === undefined || order === null) {
|
||||||
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
|
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
|
||||||
|
|
@ -110,7 +131,7 @@ router.post('/device/:deviceId', (req, res) => {
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec)
|
INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(playlistId, content_id || null, widget_id || null, zone_id || null, order, duration_sec);
|
`).run(playlistId, content_id || null, widget_id || null, effZone, order, duration_sec);
|
||||||
|
|
||||||
markDraft(playlistId);
|
markDraft(playlistId);
|
||||||
|
|
||||||
|
|
@ -139,20 +160,58 @@ function checkItemWrite(req, res) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #129: real-time mute. Tell every device on this playlist to toggle the volume of the
|
// #129 + mute-fix: per-item mute has to do TWO things, because the device plays from
|
||||||
// matching currently-playing item NOW (decoupled from publish — the device matches by
|
// playlists.published_snapshot (deviceSocket.buildPlaylistPayload), NOT the draft
|
||||||
// content_id/widget_id and applies it live). The new value is also written to the row, so
|
// playlist_items the toggle writes:
|
||||||
// it lands in the next published snapshot and persists across playlist reloads.
|
// (1) LIVE — tell every device on this playlist to silence the matching currently-playing
|
||||||
|
// item NOW (device matches by content_id/widget_id). Mutes the in-progress playthrough.
|
||||||
|
// (2) PERSIST — patch the matching item's `muted` inside the published_snapshot the device
|
||||||
|
// actually plays, then re-push the playlist. Without this the snapshot kept muted=0, so
|
||||||
|
// every loop/reload re-applied full volume — the "icon red but audio plays across 3
|
||||||
|
// playthroughs" bug (Android re-loads each loop; web's native <video> loop masked it).
|
||||||
|
// We patch the snapshot SURGICALLY (just the muted field of matching items) rather than calling
|
||||||
|
// publishPlaylist, so a mute toggle can't prematurely publish other pending draft edits or flip
|
||||||
|
// the playlist's draft/published status. muted is written as 0/1 to match buildSnapshotItems'
|
||||||
|
// format (the player reads it via optInt). playlist_items.muted is still updated by the caller,
|
||||||
|
// so a later full publish stays consistent.
|
||||||
function emitMuteChanged(req, item, muted) {
|
function emitMuteChanged(req, item, muted) {
|
||||||
try {
|
try {
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
if (!io) return;
|
if (!io) return;
|
||||||
const deviceNs = io.of('/device');
|
const deviceNs = io.of('/device');
|
||||||
|
const m = !!muted;
|
||||||
|
|
||||||
|
// (2) PERSIST: patch the published snapshot the device reads from.
|
||||||
|
const pl = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(item.playlist_id);
|
||||||
|
if (pl && pl.published_snapshot) {
|
||||||
|
let snap = null;
|
||||||
|
try { snap = JSON.parse(pl.published_snapshot); } catch (e) { snap = null; }
|
||||||
|
if (Array.isArray(snap)) {
|
||||||
|
let changed = false;
|
||||||
|
for (const s of snap) {
|
||||||
|
const match = item.content_id ? s.content_id === item.content_id
|
||||||
|
: (item.widget_id ? s.widget_id === item.widget_id : false);
|
||||||
|
if (match && (s.muted ? 1 : 0) !== (m ? 1 : 0)) { s.muted = m ? 1 : 0; changed = true; }
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
|
||||||
|
.run(JSON.stringify(snap), item.playlist_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (1) LIVE toggle + re-deliver the patched snapshot so loops re-apply the correct flag.
|
||||||
|
// Lazy require (matches playlists.pushToDevices) to avoid a route<->ws circular import.
|
||||||
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||||
|
const commandQueue = require('../lib/command-queue');
|
||||||
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id);
|
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id);
|
||||||
const payload = { content_id: item.content_id || null, widget_id: item.widget_id || null, muted: !!muted };
|
const payload = { content_id: item.content_id || null, widget_id: item.widget_id || null, muted: m };
|
||||||
for (const d of devices) deviceNs.to(d.id).emit('device:mute-changed', payload);
|
for (const d of devices) {
|
||||||
console.log(`[mute] item ${item.id} (content ${item.content_id || item.widget_id}) -> ${muted ? 'MUTED' : 'unmuted'}; notified ${devices.length} device(s)`);
|
deviceNs.to(d.id).emit('device:mute-changed', payload); // current playthrough
|
||||||
} catch (e) { /* best-effort live toggle; the published snapshot is the source of truth */ }
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.id, buildPlaylistPayload); // future loads (no reload of current item)
|
||||||
|
}
|
||||||
|
console.log(`[mute] item ${item.id} (content ${item.content_id || item.widget_id}) -> ${m ? 'MUTED' : 'unmuted'}; snapshot patched + notified ${devices.length} device(s)`);
|
||||||
|
} catch (e) { /* best-effort; playlist_items.muted is still updated for the next full publish */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update playlist item
|
// Update playlist item
|
||||||
|
|
@ -168,7 +227,17 @@ router.put('/:id', (req, res) => {
|
||||||
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); }
|
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); }
|
||||||
// zone_id can be null (clear the zone) - treat undefined as "no change",
|
// zone_id can be null (clear the zone) - treat undefined as "no change",
|
||||||
// any other value (including null) as "write this".
|
// any other value (including null) as "write this".
|
||||||
if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); }
|
if (zone_id !== undefined) {
|
||||||
|
// Hardening: if this playlist is bound to exactly ONE device with a layout, clear a
|
||||||
|
// zone_id that isn't in that layout (prevents new orphans). Multi-device / fullscreen
|
||||||
|
// playlists can't be bound to one layout here, so we leave those to the player fallback.
|
||||||
|
let effZone = zone_id || null;
|
||||||
|
if (effZone) {
|
||||||
|
const devs = db.prepare('SELECT layout_id FROM devices WHERE playlist_id = ? AND layout_id IS NOT NULL').all(item.playlist_id);
|
||||||
|
if (devs.length === 1) effZone = validZoneForLayout(effZone, devs[0].layout_id, `on update of item ${req.params.id}`);
|
||||||
|
}
|
||||||
|
updates.push('zone_id = ?'); values.push(effZone);
|
||||||
|
}
|
||||||
// #129: per-item mute (coerced to 0/1). Was silently dropped here before, so the
|
// #129: per-item mute (coerced to 0/1). Was silently dropped here before, so the
|
||||||
// dashboard toggle did nothing.
|
// dashboard toggle did nothing.
|
||||||
const mutedChanged = muted !== undefined && (item.muted ? 1 : 0) !== (muted ? 1 : 0);
|
const mutedChanged = muted !== undefined && (item.muted ? 1 : 0) !== (muted ? 1 : 0);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa
|
||||||
// or null based on the caller's reach into a specific workspace.
|
// or null based on the caller's reach into a specific workspace.
|
||||||
const { accessContext } = require('../lib/tenancy');
|
const { accessContext } = require('../lib/tenancy');
|
||||||
const { stripDeviceSecrets } = require('../lib/device-sanitize');
|
const { stripDeviceSecrets } = require('../lib/device-sanitize');
|
||||||
|
const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate');
|
||||||
|
|
||||||
// List devices in the caller's current workspace.
|
// List devices in the caller's current workspace.
|
||||||
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
|
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
|
||||||
|
|
@ -40,7 +41,10 @@ router.get('/', (req, res) => {
|
||||||
ORDER BY d.sort_order ASC, d.created_at ASC
|
ORDER BY d.sort_order ASC, d.created_at ASC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`).all(req.workspaceId, limit, offset);
|
`).all(req.workspaceId, limit, offset);
|
||||||
res.json(devices.map(stripDeviceSecrets));
|
// #zone-orphan: lightweight per-device count of playlist items whose zone_id isn't in
|
||||||
|
// the device's active layout, so the dashboard can flag screens that need attention.
|
||||||
|
const orphanCounts = orphanCountsByDevice(devices.map(d => d.id));
|
||||||
|
res.json(devices.map(d => ({ ...stripDeviceSecrets(d), orphan_count: orphanCounts[d.id] || 0 })));
|
||||||
});
|
});
|
||||||
|
|
||||||
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order
|
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order
|
||||||
|
|
@ -109,7 +113,7 @@ router.get('/:id', (req, res) => {
|
||||||
let playlist_has_published = false;
|
let playlist_has_published = false;
|
||||||
if (device.playlist_id) {
|
if (device.playlist_id) {
|
||||||
assignments = db.prepare(`
|
assignments = db.prepare(`
|
||||||
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
|
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted,
|
||||||
pi.created_at, pi.updated_at,
|
pi.created_at, pi.updated_at,
|
||||||
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
|
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
|
||||||
c.duration_sec as content_duration, c.remote_url,
|
c.duration_sec as content_duration, c.remote_url,
|
||||||
|
|
@ -127,6 +131,14 @@ router.get('/:id', (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #zone-orphan: flag any item whose zone_id isn't a zone in the device's ACTIVE layout
|
||||||
|
// (same rule as lib/zone-validate). The dashboard shows a per-item "reassign" warning;
|
||||||
|
// active_layout_zones ships the zone list here too so the inline reassign dropdown needs
|
||||||
|
// no separate /api/layouts round-trip. Informational only — playback uses the fallback.
|
||||||
|
const active_layout_zones = layoutZones(device.layout_id);
|
||||||
|
const activeZoneIdSet = new Set(active_layout_zones.map(z => z.id));
|
||||||
|
for (const a of assignments) a.orphan = !!a.zone_id && !activeZoneIdSet.has(a.zone_id);
|
||||||
|
|
||||||
// Uptime timeline: get status change events for last 24 hours
|
// Uptime timeline: get status change events for last 24 hours
|
||||||
const dayAgo = Math.floor(Date.now() / 1000) - 86400;
|
const dayAgo = Math.floor(Date.now() / 1000) - 86400;
|
||||||
let statusLog = [];
|
let statusLog = [];
|
||||||
|
|
@ -141,7 +153,7 @@ router.get('/:id', (req, res) => {
|
||||||
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
|
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
|
||||||
).all(req.params.id, dayAgo).map(r => r.reported_at);
|
).all(req.params.id, dayAgo).map(r => r.reported_at);
|
||||||
|
|
||||||
res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
|
res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper: check device write access via the workspace the device belongs to.
|
// Helper: check device write access via the workspace the device belongs to.
|
||||||
|
|
|
||||||
|
|
@ -227,20 +227,29 @@ router.post('/:id/duplicate', (req, res) => {
|
||||||
db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
|
db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
.run(newId, req.user.id, req.workspaceId, name, source.width, source.height);
|
.run(newId, req.user.id, req.workspaceId, name, source.width, source.height);
|
||||||
|
|
||||||
// Copy zones
|
// Copy zones, keeping an old->new zone-id map. The copy gets fresh zone ids, so any
|
||||||
|
// playlist_items still pointing at the SOURCE zones would be orphaned if a device is
|
||||||
|
// moved onto this copy. We return the map (zone_id_map) so a follow-up remap can run.
|
||||||
|
// NOTE for review: we intentionally do NOT auto-rewrite playlist_items.zone_id here —
|
||||||
|
// the source layout's own assignments must keep pointing at the source. A safe remap is
|
||||||
|
// a scoped op ("migrate playlist P from layout A to its copy B"), best done explicitly;
|
||||||
|
// see find-orphan-zone-items.js + the player fallback, which already de-risk the runtime.
|
||||||
const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id);
|
const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id);
|
||||||
const stmt = db.prepare(`
|
const stmt = 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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
const zone_id_map = {};
|
||||||
zones.forEach(z => {
|
zones.forEach(z => {
|
||||||
stmt.run(uuidv4(), newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
|
const nz = uuidv4();
|
||||||
|
zone_id_map[z.id] = nz;
|
||||||
|
stmt.run(nz, newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
|
||||||
z.z_index, z.zone_type, z.fit_mode, z.background_color, z.sort_order);
|
z.z_index, z.zone_type, z.fit_mode, z.background_color, z.sort_order);
|
||||||
});
|
});
|
||||||
|
|
||||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(newId);
|
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(newId);
|
||||||
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(newId);
|
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(newId);
|
||||||
res.status(201).json(layout);
|
res.status(201).json({ ...layout, zone_id_map });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign layout to device.
|
// Assign layout to device.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const fs = require('fs');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const VERSION = require('../version');
|
const VERSION = require('../version');
|
||||||
const { PLATFORM_ROLES } = require('../middleware/auth');
|
const { PLATFORM_ROLES } = require('../middleware/auth');
|
||||||
|
const loopLag = require('../services/loop-lag');
|
||||||
|
|
||||||
// Public status page
|
// Public status page
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
@ -24,6 +25,9 @@ router.get('/', (req, res) => {
|
||||||
version,
|
version,
|
||||||
uptime_human: formatUptime(uptime),
|
uptime_human: formatUptime(uptime),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
// #142: current event-loop lag snapshot, so site lag is diagnosable from the
|
||||||
|
// health endpoint independent of any throttling. Cheap (in-memory read).
|
||||||
|
loop_lag: loopLag.getLag(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -625,6 +625,10 @@ app.set('io', io);
|
||||||
const { startHeartbeatChecker } = require('./services/heartbeat');
|
const { startHeartbeatChecker } = require('./services/heartbeat');
|
||||||
startHeartbeatChecker(io);
|
startHeartbeatChecker(io);
|
||||||
|
|
||||||
|
// #142: start event-loop lag sampling (feeds /api/status + the reconnect throttle)
|
||||||
|
const { startLoopLagMonitor } = require('./services/loop-lag');
|
||||||
|
startLoopLagMonitor();
|
||||||
|
|
||||||
// Start command-queue sweep (prunes expired entries for offline devices)
|
// Start command-queue sweep (prunes expired entries for offline devices)
|
||||||
const commandQueue = require('./lib/command-queue');
|
const commandQueue = require('./lib/command-queue');
|
||||||
commandQueue.startSweep();
|
commandQueue.startSweep();
|
||||||
|
|
@ -710,13 +714,22 @@ function resolveApkPath() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #139: a device that can't silently install re-downloads the APK every check cycle. Don't
|
||||||
|
// word a download as "in progress" (it may be a stuck loop, not progress), and rate-limit the
|
||||||
|
// line to once per IP per window so a looping device can't flood the log.
|
||||||
|
const otaDownloadLoggedAt = new Map(); // ip -> last-logged ms
|
||||||
|
const OTA_DOWNLOAD_LOG_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
// Serve APK download
|
// Serve APK download
|
||||||
app.get('/download/apk', (req, res) => {
|
app.get('/download/apk', (req, res) => {
|
||||||
const apkPath = resolveApkPath();
|
const apkPath = resolveApkPath();
|
||||||
if (apkPath) {
|
if (apkPath) {
|
||||||
// #96: an APK download means a device is actually applying an OTA - log it so the
|
const ip = getClientIp(req);
|
||||||
// update is observable end to end (check -> download -> [relaunch]).
|
const now = Date.now();
|
||||||
console.log(`[ota] APK download by ${getClientIp(req)} (${fs.statSync(apkPath).size} bytes) - OTA update in progress`);
|
if (now - (otaDownloadLoggedAt.get(ip) || 0) > OTA_DOWNLOAD_LOG_WINDOW_MS) {
|
||||||
|
otaDownloadLoggedAt.set(ip, now);
|
||||||
|
console.log(`[ota] APK served to ${ip} (${fs.statSync(apkPath).size} bytes)`);
|
||||||
|
}
|
||||||
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"');
|
res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const { db } = require('../db/database');
|
const { db, pruneStatusLog } = require('../db/database');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
|
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
|
||||||
|
|
||||||
|
|
@ -6,6 +6,10 @@ const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
|
||||||
const deviceConnections = new Map();
|
const deviceConnections = new Map();
|
||||||
|
|
||||||
function startHeartbeatChecker(io) {
|
function startHeartbeatChecker(io) {
|
||||||
|
// #142: sweep stale device_status_log rows once at startup (recovers a bloated
|
||||||
|
// table immediately after a deploy), then again on each interval below.
|
||||||
|
pruneStatusLog();
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const dashboardNs = io.of('/dashboard');
|
const dashboardNs = io.of('/dashboard');
|
||||||
|
|
@ -36,19 +40,18 @@ function startHeartbeatChecker(io) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup: delete unclaimed provisioning devices older than 24 hours
|
// Cleanup: delete unclaimed provisioning devices older than 24 hours.
|
||||||
// Keep imported devices (they have user_id set) so users can re-pair them
|
pruneProvisioningDevices();
|
||||||
db.prepare(`
|
|
||||||
DELETE FROM devices WHERE status = 'provisioning'
|
|
||||||
AND user_id IS NULL
|
|
||||||
AND created_at < strftime('%s','now') - (365 * 86400)
|
|
||||||
`).run();
|
|
||||||
|
|
||||||
// Cleanup: prune play logs older than 90 days
|
// Cleanup: prune play logs older than 90 days
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
DELETE FROM play_logs WHERE started_at < strftime('%s','now') - (90 * 86400)
|
DELETE FROM play_logs WHERE started_at < strftime('%s','now') - (90 * 86400)
|
||||||
`).run();
|
`).run();
|
||||||
|
|
||||||
|
// #142: global device_status_log retention sweep (all devices, incl. removed/idle
|
||||||
|
// and the offline_timeout insert path that bypasses the per-device prune).
|
||||||
|
pruneStatusLog();
|
||||||
|
|
||||||
// Cleanup: expired team invites
|
// Cleanup: expired team invites
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
DELETE FROM team_invites WHERE expires_at < strftime('%s','now')
|
DELETE FROM team_invites WHERE expires_at < strftime('%s','now')
|
||||||
|
|
@ -83,11 +86,25 @@ function getAllConnections() {
|
||||||
return deviceConnections;
|
return deviceConnections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #142: sweep unclaimed provisioning devices older than 24h. The window previously
|
||||||
|
// read `365 * 86400` (a YEAR), contradicting its own "older than 24 hours" comment,
|
||||||
|
// so socket-register pairing junk lingered far longer than intended. Imported
|
||||||
|
// devices keep a user_id and are preserved so they can be re-paired. Extracted from
|
||||||
|
// the interval above so the correctness fix is unit-testable. Returns rows deleted.
|
||||||
|
function pruneProvisioningDevices() {
|
||||||
|
return db.prepare(`
|
||||||
|
DELETE FROM devices
|
||||||
|
WHERE status = 'provisioning' AND user_id IS NULL
|
||||||
|
AND created_at < strftime('%s','now') - (24 * 3600)
|
||||||
|
`).run().changes;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
startHeartbeatChecker,
|
startHeartbeatChecker,
|
||||||
registerConnection,
|
registerConnection,
|
||||||
updateHeartbeat,
|
updateHeartbeat,
|
||||||
removeConnection,
|
removeConnection,
|
||||||
getConnection,
|
getConnection,
|
||||||
getAllConnections
|
getAllConnections,
|
||||||
|
pruneProvisioningDevices
|
||||||
};
|
};
|
||||||
|
|
|
||||||
107
server/services/loop-lag.js
Normal file
107
server/services/loop-lag.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// #142 — Event-loop lag telemetry (the data subsystem; ships before the throttle).
|
||||||
|
//
|
||||||
|
// Continuously samples event-loop delay via perf_hooks.monitorEventLoopDelay()
|
||||||
|
// (a C++-backed histogram — cheap). Each window we read mean/p50/p99/max, persist
|
||||||
|
// a row to the bounded `event_loop_lag` table, and recompute a coarse load BAND
|
||||||
|
// (normal | elevated | critical) from the window p99.
|
||||||
|
//
|
||||||
|
// The band is consumed by the reconnect throttle (#142 step 3), but this module
|
||||||
|
// has standalone value: getLag() is surfaced on /api/status and band changes are
|
||||||
|
// logged, so site connectivity/lag is diagnosable independent of any throttling.
|
||||||
|
//
|
||||||
|
// Band transitions are deliberately asymmetric (see nextBand): jump UP immediately
|
||||||
|
// when an up-threshold is crossed (tighten fast), step DOWN only one level at a
|
||||||
|
// time after lagReleaseSamples consecutive calm samples below a deadband (release
|
||||||
|
// slow). This avoids band flap from transient blips.
|
||||||
|
|
||||||
|
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||||
|
const { db } = require('../db/database');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
const NS_PER_MS = 1e6;
|
||||||
|
// A band releases only once p99 falls below this fraction of the band's entry
|
||||||
|
// threshold — the deadband that stops small fluctuations from flapping the band.
|
||||||
|
const DEADBAND = 0.5;
|
||||||
|
const LEVEL = { normal: 0, elevated: 1, critical: 2 };
|
||||||
|
|
||||||
|
let histogram = null;
|
||||||
|
let band = 'normal';
|
||||||
|
let calmSamples = 0;
|
||||||
|
let current = { mean_ms: 0, p50_ms: 0, p99_ms: 0, max_ms: 0, band: 'normal', sampled_at: 0 };
|
||||||
|
|
||||||
|
// Pure band-transition function (exported for deterministic unit tests). Given the
|
||||||
|
// current band, the window p99 (ms), and the running calm-sample count, returns the
|
||||||
|
// next [band, calmSamples]. Up is immediate (may skip a level); down is one step
|
||||||
|
// per release window, gated by a deadband.
|
||||||
|
function nextBand(cur, p99, calm) {
|
||||||
|
const level = LEVEL[cur] ?? 0;
|
||||||
|
// UP — immediate, tighten fast (normal can jump straight to critical).
|
||||||
|
if (p99 >= config.lagCriticalMs && level < LEVEL.critical) return ['critical', 0];
|
||||||
|
if (p99 >= config.lagElevatedMs && level < LEVEL.elevated) return ['elevated', 0];
|
||||||
|
// DOWN — slow, one step, only below the current band's deadband.
|
||||||
|
if (level === LEVEL.critical && p99 <= config.lagCriticalMs * DEADBAND) {
|
||||||
|
const c = calm + 1;
|
||||||
|
return c >= config.lagReleaseSamples ? ['elevated', 0] : ['critical', c];
|
||||||
|
}
|
||||||
|
if (level === LEVEL.elevated && p99 <= config.lagElevatedMs * DEADBAND) {
|
||||||
|
const c = calm + 1;
|
||||||
|
return c >= config.lagReleaseSamples ? ['normal', 0] : ['elevated', c];
|
||||||
|
}
|
||||||
|
// Hold (inside deadband, or already normal): reset the calm counter.
|
||||||
|
return [cur, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (x) => Math.round(x * 100) / 100;
|
||||||
|
|
||||||
|
function sample() {
|
||||||
|
const p99 = histogram.percentile(99) / NS_PER_MS;
|
||||||
|
const snap = {
|
||||||
|
mean_ms: round2(histogram.mean / NS_PER_MS),
|
||||||
|
p50_ms: round2(histogram.percentile(50) / NS_PER_MS),
|
||||||
|
p99_ms: round2(p99),
|
||||||
|
max_ms: round2(histogram.max / NS_PER_MS),
|
||||||
|
};
|
||||||
|
histogram.reset();
|
||||||
|
|
||||||
|
const prev = band;
|
||||||
|
[band, calmSamples] = nextBand(band, snap.p99_ms, calmSamples);
|
||||||
|
current = { ...snap, band, sampled_at: Math.floor(Date.now() / 1000) };
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO event_loop_lag (sampled_at, mean_ms, p50_ms, p99_ms, max_ms, band) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(current.sampled_at, snap.mean_ms, snap.p50_ms, snap.p99_ms, snap.max_ms, band);
|
||||||
|
} catch (_) { /* table may not exist on a partially-migrated DB */ }
|
||||||
|
|
||||||
|
// Observable: log whenever we're loaded or when the band changes (incl. back to
|
||||||
|
// normal). Healthy steady state stays quiet.
|
||||||
|
if (band !== 'normal' || prev !== 'normal') {
|
||||||
|
const tag = band !== prev ? ` (was ${prev})` : '';
|
||||||
|
console.log(`[loop-lag] band=${band}${tag} mean=${snap.mean_ms}ms p99=${snap.p99_ms}ms max=${snap.max_ms}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneLag() {
|
||||||
|
try {
|
||||||
|
const cutoff = Math.floor(Date.now() / 1000) - Math.round(config.lagTelemetryRetentionDays * 86400);
|
||||||
|
const n = db.prepare('DELETE FROM event_loop_lag WHERE sampled_at < ?').run(cutoff).changes;
|
||||||
|
if (n > 0) console.log(`[loop-lag] pruned ${n} sample(s) older than ${config.lagTelemetryRetentionDays}d`);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoopLagMonitor() {
|
||||||
|
if (histogram) return; // idempotent
|
||||||
|
histogram = monitorEventLoopDelay({ resolution: config.lagResolutionMs });
|
||||||
|
histogram.enable();
|
||||||
|
const t1 = setInterval(sample, config.lagSampleIntervalMs);
|
||||||
|
pruneLag(); // sweep stale rows on boot
|
||||||
|
const t2 = setInterval(pruneLag, config.lagPruneIntervalMs);
|
||||||
|
// Don't keep the process alive on these timers (matters for tests / clean exit).
|
||||||
|
if (t1.unref) t1.unref();
|
||||||
|
if (t2.unref) t2.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBand() { return band; }
|
||||||
|
function getLag() { return { ...current }; }
|
||||||
|
|
||||||
|
module.exports = { startLoopLagMonitor, getBand, getLag, nextBand };
|
||||||
|
|
@ -259,6 +259,32 @@ test('device WS: wrong device_token is rejected (auth-error, never registered)',
|
||||||
assert.ok(!got.registered, 'wrong token must not register');
|
assert.ok(!got.registered, 'wrong token must not register');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #139 Phase 2 (Option B): event-driven OTA status. Registers (which, with no ota fields in
|
||||||
|
// device_info, persists ota_status='none' via the backstop), then emits a valid ota-status and
|
||||||
|
// a foreign-id one in order on the authenticated socket.
|
||||||
|
function deviceOtaSeq(payload, otaEvents, timeoutMs = 4000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
||||||
|
const finish = () => { try { sock.close(); } catch { /* */ } resolve(); };
|
||||||
|
sock.on('connect', () => sock.emit('device:register', payload));
|
||||||
|
sock.on('device:registered', () => { for (const e of otaEvents) sock.emit('device:ota-status', e); setTimeout(finish, 500); });
|
||||||
|
sock.on('device:auth-error', finish);
|
||||||
|
setTimeout(finish, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('device WS: device:ota-status persists the fields; a foreign device_id is a safe no-op (#139)', async () => {
|
||||||
|
await deviceOtaSeq(
|
||||||
|
{ device_id: S.deviceId, device_token: S.deviceToken, device_info: { app_version: 'test' } },
|
||||||
|
[
|
||||||
|
{ device_id: S.deviceId, ota_status: 'manual_update_required', ota_target_version: '1.9.1-beta6', ota_attempts: 3 },
|
||||||
|
{ device_id: 'nope-not-a-device', ota_status: 'none', ota_target_version: null, ota_attempts: 0 }, // foreign id -> no-op, no throw
|
||||||
|
]);
|
||||||
|
const dev = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
|
||||||
|
assert.equal(dev.body.ota_status, 'manual_update_required', 'valid ota-status persisted');
|
||||||
|
assert.equal(dev.body.ota_target_version, '1.9.1-beta6');
|
||||||
|
assert.equal(dev.body.ota_attempts, 3, 'and the foreign-id event did not overwrite it');
|
||||||
|
});
|
||||||
|
|
||||||
// ───────────────────────── TIER 4: #92 FOLLOW-UP COVERAGE ─────────────────────────
|
// ───────────────────────── TIER 4: #92 FOLLOW-UP COVERAGE ─────────────────────────
|
||||||
// The non-security gaps named in the self-review (issue #92): the gap-fix fields + the
|
// The non-security gaps named in the self-review (issue #92): the gap-fix fields + the
|
||||||
// cross-tenant guard (the security-relevant one), docs serving, and the token lifecycle
|
// cross-tenant guard (the security-relevant one), docs serving, and the token lifecycle
|
||||||
|
|
|
||||||
85
server/test/content-ack-dedup.test.js
Normal file
85
server/test/content-ack-dedup.test.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #142 step 5 — content-ack dedup. Repeated identical (device_id, content_id, status)
|
||||||
|
// reports are suppressed within config.contentAckDedupMs; a status change or a report
|
||||||
|
// after the window passes. Observed via the server log (the handler logs+emits only
|
||||||
|
// when it does NOT dedup). Unique PORT (3984) to avoid the collision class.
|
||||||
|
|
||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const ioClient = require('socket.io-client');
|
||||||
|
|
||||||
|
const PORT = 3984;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}`;
|
||||||
|
const DATA_DIR = path.join(os.tmpdir(), 'st-ack-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
const LOG = path.join(os.tmpdir(), 'st-ack-' + crypto.randomBytes(4).toString('hex') + '.log');
|
||||||
|
const DEDUP_MS = 600;
|
||||||
|
let proc;
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const logFd = fs.openSync(LOG, 'w');
|
||||||
|
proc = spawn('node', ['server.js'], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test', CONTENT_ACK_DEDUP_MS: String(DEDUP_MS) },
|
||||||
|
stdio: ['ignore', logFd, logFd],
|
||||||
|
});
|
||||||
|
let up = false;
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
||||||
|
|
||||||
|
function provision() {
|
||||||
|
const code = String(crypto.randomInt(100000, 1000000));
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
||||||
|
sock.on('connect', () => sock.emit('device:register', { pairing_code: code }));
|
||||||
|
sock.on('device:registered', (d) => { try { sock.close(); } catch { /* */ } resolve({ id: d.device_id, token: d.device_token }); });
|
||||||
|
setTimeout(() => resolve(null), 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRegistered(dev) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
||||||
|
sock.on('connect', () => sock.emit('device:register', { device_id: dev.id, device_token: dev.token, device_info: { app_version: 'test' } }));
|
||||||
|
sock.on('device:registered', () => resolve(sock));
|
||||||
|
sock.on('device:auth-error', () => reject(new Error('auth-error')));
|
||||||
|
setTimeout(() => reject(new Error('register timeout')), 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('repeated identical content-acks are deduped; window-expiry and status-change pass', async () => {
|
||||||
|
const dev = await provision();
|
||||||
|
assert.ok(dev, 'device provisioned');
|
||||||
|
const sock = await openRegistered(dev);
|
||||||
|
const cid = 'cid-' + crypto.randomBytes(3).toString('hex');
|
||||||
|
|
||||||
|
// 5 rapid identical "ready" within the dedup window -> only ONE should log/emit
|
||||||
|
for (let i = 0; i < 5; i++) { sock.emit('device:content-ack', { device_id: dev.id, content_id: cid, status: 'ready' }); await sleep(40); }
|
||||||
|
// wait past the window, then "ready" again -> passes (a fresh report)
|
||||||
|
await sleep(DEDUP_MS + 250);
|
||||||
|
sock.emit('device:content-ack', { device_id: dev.id, content_id: cid, status: 'ready' });
|
||||||
|
// a status CHANGE has a different key -> passes immediately
|
||||||
|
await sleep(60);
|
||||||
|
sock.emit('device:content-ack', { device_id: dev.id, content_id: cid, status: 'error' });
|
||||||
|
await sleep(400);
|
||||||
|
try { sock.close(); } catch { /* */ }
|
||||||
|
|
||||||
|
const log = fs.readFileSync(LOG, 'utf8');
|
||||||
|
const ready = (log.match(new RegExp(`content ${cid}: ready`, 'g')) || []).length;
|
||||||
|
const err = (log.match(new RegExp(`content ${cid}: error`, 'g')) || []).length;
|
||||||
|
assert.equal(ready, 2, 'a burst of identical "ready" collapses to one; a second after the window passes -> 2 total');
|
||||||
|
assert.equal(err, 1, 'a status change is not deduped');
|
||||||
|
});
|
||||||
166
server/test/device-zone-contract.test.js
Normal file
166
server/test/device-zone-contract.test.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression tests for the SERVER-SIDE data contracts added by the mute + zone-orphan
|
||||||
|
// branch. These guard the exact bugs we fixed so they can't silently come back:
|
||||||
|
// 1. GET /api/devices/:id must carry each item's `muted` — and BOTH true and false
|
||||||
|
// (the bug was a SELECT that dropped the column; the false case is the one that broke).
|
||||||
|
// 2. GET /api/devices/:id must return `active_layout_zones` for a multi-zone device
|
||||||
|
// (the contract the dashboard zone-selector now depends on).
|
||||||
|
// 3. The single-source orphan rule (lib/zone-validate): a zone in the active layout is
|
||||||
|
// NOT orphaned; a zone from a DIFFERENT layout IS — surfaced as the per-item `orphan`
|
||||||
|
// flag and the device-list `orphan_count`.
|
||||||
|
// 4. Reassigning an orphan to a valid zone drops `orphan_count` to 0.
|
||||||
|
// 5. Assign-time hardening: a zone_id not in the device's active layout is cleared to
|
||||||
|
// null on POST; a valid one is kept.
|
||||||
|
//
|
||||||
|
// Mirrors mute.test.js: boots the real server.js against an isolated DB and seeds rows on
|
||||||
|
// one connection (FK off) to avoid WAL visibility races. No player/DOM/Playwright tests.
|
||||||
|
|
||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const PORT = 3996;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}`;
|
||||||
|
const DATA_DIR = path.join(os.tmpdir(), 'st-zone-test-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
const LOG = path.join(os.tmpdir(), 'st-zone-' + crypto.randomBytes(4).toString('hex') + '.log');
|
||||||
|
const PW = 'Passw0rd123';
|
||||||
|
let proc, db;
|
||||||
|
const S = {};
|
||||||
|
|
||||||
|
async function jfetch(p, opts = {}) {
|
||||||
|
const res = await fetch(BASE + p, opts);
|
||||||
|
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
|
||||||
|
return { status: res.status, body };
|
||||||
|
}
|
||||||
|
const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' } });
|
||||||
|
const post = (tok, obj) => ({ method: 'POST', ...auth(tok), body: JSON.stringify(obj || {}) });
|
||||||
|
const put = (tok, obj) => ({ method: 'PUT', ...auth(tok), body: JSON.stringify(obj || {}) });
|
||||||
|
|
||||||
|
// Find one item in the device payload by playlist_item id (ids are integers; coerce both).
|
||||||
|
async function getAssignment(itemId) {
|
||||||
|
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
|
||||||
|
return (r.body.assignments || []).find((a) => Number(a.id) === Number(itemId));
|
||||||
|
}
|
||||||
|
// Read the device's orphan_count off the workspace device list.
|
||||||
|
async function getOrphanCount() {
|
||||||
|
const r = await jfetch('/api/devices', auth(S.jwt));
|
||||||
|
const d = (r.body || []).find((x) => x.id === S.deviceId);
|
||||||
|
return d ? d.orphan_count : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const logFd = fs.openSync(LOG, 'w');
|
||||||
|
proc = spawn('node', ['server.js'], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
|
||||||
|
stdio: ['ignore', logFd, logFd],
|
||||||
|
});
|
||||||
|
let up = false;
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
||||||
|
|
||||||
|
// First user -> platform_admin; register returns the JWT, the user, and the workspace.
|
||||||
|
const reg = await jfetch('/api/auth/register', post(null, { email: 'z' + crypto.randomBytes(4).toString('hex') + '@x.local', password: PW }));
|
||||||
|
S.jwt = reg.body.token;
|
||||||
|
S.userId = reg.body.user.id;
|
||||||
|
S.wsA = reg.body.current_workspace_id;
|
||||||
|
|
||||||
|
// Active multi-zone layout L1 (Main, Side) + a DIFFERENT layout L2 (Other) — via the API
|
||||||
|
// so zone ids are real and workspace-scoped. L2's zone is the "different layout" orphan.
|
||||||
|
const l1 = await jfetch('/api/layouts', post(S.jwt, { name: 'L1', zones: [{ name: 'Main', width_percent: 60, height_percent: 100 }, { name: 'Side', width_percent: 40, height_percent: 100 }] }));
|
||||||
|
S.L1 = l1.body.id; S.Z1 = l1.body.zones[0].id; S.Z2 = l1.body.zones[1].id;
|
||||||
|
const l2 = await jfetch('/api/layouts', post(S.jwt, { name: 'L2', zones: [{ name: 'Other', width_percent: 100, height_percent: 100 }] }));
|
||||||
|
S.ZX = l2.body.zones[0].id;
|
||||||
|
|
||||||
|
const pl = await jfetch('/api/playlists', post(S.jwt, { name: 'zone-pl' }));
|
||||||
|
S.playlistId = pl.body.id;
|
||||||
|
|
||||||
|
// Seed content, a device, and playlist_items on one connection (FK off). The orphan item
|
||||||
|
// is seeded DIRECTLY so it bypasses assign-time validation — that's how real orphans
|
||||||
|
// arise (assigned under a different layout / layout switched after the fact).
|
||||||
|
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
|
||||||
|
db.pragma('foreign_keys = OFF');
|
||||||
|
const mkContent = (name) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
db.prepare("INSERT INTO content (id, filename, filepath, mime_type, file_size, remote_url) VALUES (?,?,?,?,0,?)")
|
||||||
|
.run(id, name, '', 'image/png', 'https://example.com/' + name + '.png');
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
S.cMute = mkContent('mute'); S.cValid = mkContent('valid'); S.cOrphan = mkContent('orphan');
|
||||||
|
S.cPostStale = mkContent('post-stale'); S.cPostOk = mkContent('post-ok');
|
||||||
|
|
||||||
|
S.deviceId = crypto.randomUUID();
|
||||||
|
db.prepare("INSERT INTO devices (id, name, status, workspace_id, user_id, layout_id, playlist_id) VALUES (?,?,?,?,?,?,?)")
|
||||||
|
.run(S.deviceId, 'ZoneDev', 'online', S.wsA, S.userId, S.L1, S.playlistId);
|
||||||
|
|
||||||
|
const addItem = (contentId, zoneId, sort) =>
|
||||||
|
db.prepare("INSERT INTO playlist_items (playlist_id, content_id, zone_id, sort_order, duration_sec, muted) VALUES (?,?,?,?,10,0)")
|
||||||
|
.run(S.playlistId, contentId, zoneId, sort).lastInsertRowid;
|
||||||
|
S.itemMute = addItem(S.cMute, null, 0); // no zone — for the mute round-trip
|
||||||
|
S.itemValid = addItem(S.cValid, S.Z1, 1); // zone in the active layout -> NOT orphan
|
||||||
|
S.itemOrphan = addItem(S.cOrphan, S.ZX, 2); // zone from L2 -> orphan
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
try { db?.close(); } catch { /* */ }
|
||||||
|
if (proc) proc.kill('SIGKILL');
|
||||||
|
for (const f of [DATA_DIR, LOG]) { try { fs.rmSync(f, { recursive: true, force: true }); } catch { /* */ } }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. muted must round-trip through the device payload SELECT — both states.
|
||||||
|
test('GET /api/devices/:id carries per-item muted (true AND false)', async () => {
|
||||||
|
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: true }));
|
||||||
|
let a = await getAssignment(S.itemMute);
|
||||||
|
assert.ok(a, 'item appears in the device payload');
|
||||||
|
assert.equal(a.muted, 1, 'muted=true survives the GET /api/devices/:id SELECT');
|
||||||
|
|
||||||
|
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: false }));
|
||||||
|
a = await getAssignment(S.itemMute);
|
||||||
|
assert.equal(a.muted, 0, 'muted=false survives too (the case that originally broke)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. active_layout_zones contract for a multi-zone device.
|
||||||
|
test('GET /api/devices/:id returns active_layout_zones for a multi-zone device', async () => {
|
||||||
|
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
|
||||||
|
const zones = r.body.active_layout_zones;
|
||||||
|
assert.ok(Array.isArray(zones), 'active_layout_zones is present');
|
||||||
|
assert.equal(zones.length, 2, 'both zones of the active layout are returned');
|
||||||
|
assert.deepEqual(zones.map((z) => z.id).sort(), [S.Z1, S.Z2].sort(), 'exactly the active-layout zone ids');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. orphan definition: in-layout zone -> not orphan; different-layout zone -> orphan.
|
||||||
|
test('orphan flag + orphan_count reflect the single-source rule', async () => {
|
||||||
|
const valid = await getAssignment(S.itemValid);
|
||||||
|
const orphan = await getAssignment(S.itemOrphan);
|
||||||
|
assert.equal(valid.orphan, false, 'a zone in the active layout is NOT orphaned');
|
||||||
|
assert.equal(orphan.orphan, true, 'a zone from a different layout IS orphaned');
|
||||||
|
assert.equal(await getOrphanCount(), 1, 'device list orphan_count counts exactly the one orphan');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. reassigning the orphan to a valid zone clears the count.
|
||||||
|
test('reassigning an orphan to a valid zone clears orphan_count', async () => {
|
||||||
|
assert.equal(await getOrphanCount(), 1, 'precondition: one orphan');
|
||||||
|
const r = await jfetch(`/api/assignments/${S.itemOrphan}`, put(S.jwt, { zone_id: S.Z1 }));
|
||||||
|
assert.equal(r.body.zone_id, S.Z1, 'reassignment to a valid zone persists');
|
||||||
|
assert.equal(await getOrphanCount(), 0, 'orphan_count drops to 0 after reassign');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. assign-time hardening: stale zone_id cleared, valid kept.
|
||||||
|
test('POST assignment clears a stale zone_id and keeps a valid one', async () => {
|
||||||
|
const stale = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostStale, zone_id: S.ZX, duration_sec: 10 }));
|
||||||
|
assert.equal(stale.status, 201);
|
||||||
|
assert.equal(stale.body.zone_id, null, 'a zone_id from a different layout is cleared to null on add');
|
||||||
|
|
||||||
|
const ok = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostOk, zone_id: S.Z2, duration_sec: 10 }));
|
||||||
|
assert.equal(ok.status, 201);
|
||||||
|
assert.equal(ok.body.zone_id, S.Z2, 'a zone_id in the active layout is kept');
|
||||||
|
});
|
||||||
64
server/test/loop-lag-integration.test.js
Normal file
64
server/test/loop-lag-integration.test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #142 step 2 — integration: the lag monitor samples, persists to a BOUNDED table,
|
||||||
|
// and surfaces current lag on /api/status. Boots the real server with fast sampling
|
||||||
|
// and a tiny (fractional-day) retention so the prune is observable within the test.
|
||||||
|
|
||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const PORT = 3982;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}`;
|
||||||
|
const DATA_DIR = path.join(os.tmpdir(), 'st-lag-int-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
const LOG = path.join(os.tmpdir(), 'st-lag-int-' + crypto.randomBytes(4).toString('hex') + '.log');
|
||||||
|
let proc;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const logFd = fs.openSync(LOG, 'w');
|
||||||
|
proc = spawn('node', ['server.js'], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
env: {
|
||||||
|
...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test',
|
||||||
|
LAG_SAMPLE_INTERVAL_MS: '200', // sample fast
|
||||||
|
LAG_TELEMETRY_RETENTION_DAYS: '0.00001', // ~0.86s retention
|
||||||
|
LAG_PRUNE_INTERVAL_MS: '400', // prune often
|
||||||
|
},
|
||||||
|
stdio: ['ignore', logFd, logFd],
|
||||||
|
});
|
||||||
|
let up = false;
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
||||||
|
|
||||||
|
test('/api/status exposes a current loop_lag snapshot', async () => {
|
||||||
|
const r = await fetch(BASE + '/api/status');
|
||||||
|
const body = await r.json();
|
||||||
|
assert.ok(body.loop_lag, 'loop_lag present on /api/status');
|
||||||
|
assert.ok(['normal', 'elevated', 'critical'].includes(body.loop_lag.band), 'band is a valid level');
|
||||||
|
assert.equal(typeof body.loop_lag.p99_ms, 'number', 'p99_ms is numeric');
|
||||||
|
assert.equal(typeof body.loop_lag.mean_ms, 'number', 'mean_ms is numeric');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lag samples are persisted AND bounded by retention prune (not unbounded)', async () => {
|
||||||
|
// Let it sample for ~3s. At 200ms/sample that is ~15 inserts, but with ~0.86s
|
||||||
|
// retention pruned every 400ms the table must stay small — proving the table
|
||||||
|
// can never become a second unbounded-growth table.
|
||||||
|
await new Promise(r => setTimeout(r, 1800));
|
||||||
|
const dbPath = path.join(DATA_DIR, 'db', 'remote_display.db');
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
const count = db.prepare('SELECT COUNT(*) c FROM event_loop_lag').get().c;
|
||||||
|
db.close();
|
||||||
|
assert.ok(count >= 1, 'lag samples are being persisted');
|
||||||
|
assert.ok(count < 15, `table is bounded by the prune (held ${count} rows over ~3s of 200ms sampling)`);
|
||||||
|
});
|
||||||
57
server/test/loop-lag.test.js
Normal file
57
server/test/loop-lag.test.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #142 step 2 — deterministic unit tests for the event-loop-lag band transitions.
|
||||||
|
// Pure function, no sockets/timing. Isolate the DB to a temp dir BEFORE requiring
|
||||||
|
// the module (requiring it pulls in db/database, which initialises a DB on load).
|
||||||
|
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-lag-unit-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { nextBand } = require('../services/loop-lag');
|
||||||
|
|
||||||
|
// config defaults exercised here: elevated=100ms, critical=250ms, releaseSamples=5,
|
||||||
|
// deadband=0.5 -> release-below thresholds: elevated@50ms, critical@125ms.
|
||||||
|
|
||||||
|
test('UP is immediate and can skip a level (tighten fast)', () => {
|
||||||
|
assert.deepEqual(nextBand('normal', 50, 0), ['normal', 0], 'below elevated stays normal');
|
||||||
|
assert.deepEqual(nextBand('normal', 100, 0), ['elevated', 0], 'crossing elevated up-threshold jumps immediately');
|
||||||
|
assert.deepEqual(nextBand('normal', 250, 0), ['critical', 0], 'a big spike jumps normal->critical in one sample');
|
||||||
|
assert.deepEqual(nextBand('elevated', 250, 0), ['critical', 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deadband holds the band for small fluctuations (no flap)', () => {
|
||||||
|
// elevated, p99 between release(50) and up(100) -> hold elevated, calm reset
|
||||||
|
assert.deepEqual(nextBand('elevated', 80, 3), ['elevated', 0]);
|
||||||
|
// critical, p99 between release(125) and up(250) -> hold critical
|
||||||
|
assert.deepEqual(nextBand('critical', 200, 4), ['critical', 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOWN is slow: requires lagReleaseSamples calm samples below the deadband', () => {
|
||||||
|
// elevated -> normal only after 5 consecutive calm samples
|
||||||
|
let band = 'elevated', calm = 0;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
[band, calm] = nextBand(band, 20, calm);
|
||||||
|
assert.equal(band, 'elevated', `still elevated after ${i + 1} calm sample(s)`);
|
||||||
|
}
|
||||||
|
[band, calm] = nextBand(band, 20, calm); // 5th
|
||||||
|
assert.deepEqual([band, calm], ['normal', 0], 'drops to normal on the 5th calm sample');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOWN releases one level at a time: critical -> elevated -> normal', () => {
|
||||||
|
let band = 'critical', calm = 0;
|
||||||
|
for (let i = 0; i < 5; i++) [band, calm] = nextBand(band, 10, calm);
|
||||||
|
assert.equal(band, 'elevated', 'critical releases to elevated, never straight to normal');
|
||||||
|
for (let i = 0; i < 5; i++) [band, calm] = nextBand(band, 10, calm);
|
||||||
|
assert.equal(band, 'normal', 'then elevated releases to normal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single calm sample does not release (calm counter resets on a non-calm sample)', () => {
|
||||||
|
let [band, calm] = nextBand('elevated', 20, 0); // calm=1
|
||||||
|
assert.deepEqual([band, calm], ['elevated', 1]);
|
||||||
|
[band, calm] = nextBand(band, 80, calm); // back inside deadband -> reset
|
||||||
|
assert.deepEqual([band, calm], ['elevated', 0], 'one blip resets the release counter');
|
||||||
|
});
|
||||||
|
|
@ -91,6 +91,24 @@ test('muted reaches the device via the published snapshot (buildSnapshotItems)',
|
||||||
assert.equal(item.muted, 1, 'snapshot (device payload) carries muted=1');
|
assert.equal(item.muted, 1, 'snapshot (device payload) carries muted=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mute toggle patches the published snapshot WITHOUT a manual republish (the beta7 bug)', async () => {
|
||||||
|
// Baseline: publish once so the device has a snapshot carrying muted=0.
|
||||||
|
await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: false }));
|
||||||
|
await jfetch(`/api/playlists/${S.playlistId}/publish`, post(S.jwt, {}));
|
||||||
|
const read = () => JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(S.playlistId).published_snapshot)
|
||||||
|
.find((i) => i.content_id === S.contentId).muted;
|
||||||
|
assert.equal(read(), 0, 'baseline: snapshot the device plays carries muted=0');
|
||||||
|
|
||||||
|
// The actual bug: a mute toggle ALONE (no /publish) must reach the played snapshot.
|
||||||
|
// On beta7 this stayed 0 (markDraft only) so every loop re-applied full volume.
|
||||||
|
await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: true }));
|
||||||
|
assert.equal(read(), 1, 'mute toggle patched the snapshot the device plays — no manual republish needed');
|
||||||
|
|
||||||
|
// Unmute toggle reverts the snapshot too.
|
||||||
|
await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: false }));
|
||||||
|
assert.equal(read(), 0, 'unmute toggle patched the snapshot back to 0');
|
||||||
|
});
|
||||||
|
|
||||||
test('PUT ignoring muted (other field) leaves muted untouched', async () => {
|
test('PUT ignoring muted (other field) leaves muted untouched', async () => {
|
||||||
await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: true }));
|
await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: true }));
|
||||||
const r = await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { duration_sec: 15 }));
|
const r = await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { duration_sec: 15 }));
|
||||||
|
|
|
||||||
41
server/test/provisioning-cleanup.test.js
Normal file
41
server/test/provisioning-cleanup.test.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #142 (cut 2) — provisioning-row cleanup window correctness. The sweep deletes
|
||||||
|
// UNCLAIMED provisioning devices older than 24h (it previously used 365*86400 — a
|
||||||
|
// year — contradicting its own comment). Imported devices (user_id set) and
|
||||||
|
// non-provisioning devices are preserved. Deterministic, in-process (no server).
|
||||||
|
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-provclean-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { db } = require('../db/database');
|
||||||
|
const { pruneProvisioningDevices } = require('../services/heartbeat');
|
||||||
|
|
||||||
|
test('sweeps unclaimed provisioning devices older than 24h, keeps the rest', () => {
|
||||||
|
db.pragma('foreign_keys = OFF'); // seed user_id without a real users row
|
||||||
|
db.exec('DELETE FROM devices');
|
||||||
|
const ins = db.prepare("INSERT INTO devices (id, status, user_id, created_at) VALUES (?, ?, ?, strftime('%s','now') - ?)");
|
||||||
|
ins.run('old-unclaimed', 'provisioning', null, 25 * 3600); // >24h, unclaimed -> SWEPT
|
||||||
|
ins.run('new-unclaimed', 'provisioning', null, 1 * 3600); // <24h, unclaimed -> kept
|
||||||
|
ins.run('old-imported', 'provisioning', 'u-imported', 25 * 3600); // >24h but imported (user_id) -> kept
|
||||||
|
ins.run('old-online', 'online', null, 25 * 3600); // >24h but not provisioning -> kept
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) c FROM devices').get().c, 4, 'seeded 4');
|
||||||
|
|
||||||
|
const deleted = pruneProvisioningDevices();
|
||||||
|
assert.equal(deleted, 1, 'only the >24h unclaimed provisioning device is swept');
|
||||||
|
|
||||||
|
const ids = db.prepare('SELECT id FROM devices ORDER BY id').all().map(r => r.id);
|
||||||
|
assert.deepEqual(ids, ['new-unclaimed', 'old-imported', 'old-online']);
|
||||||
|
// regression guard: a 25h-old row sits well inside the OLD 365-day window, so this
|
||||||
|
// would have survived before the fix.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idempotent: a second sweep with nothing stale deletes nothing', () => {
|
||||||
|
assert.equal(pruneProvisioningDevices(), 0);
|
||||||
|
});
|
||||||
113
server/test/reconnect-throttle-integration.test.js
Normal file
113
server/test/reconnect-throttle-integration.test.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #142 step 3 — REQUIRED GATE TEST + storm + neighbor, over real sockets.
|
||||||
|
//
|
||||||
|
// Boots the real server with warm-up ACTIVE (default) so the whole suite runs in
|
||||||
|
// the cold-start window — the exact "right after a deploy" scenario. Hard ceiling
|
||||||
|
// and window are tightened so the storm trips quickly without thousands of connects;
|
||||||
|
// fleet devices stay well under the ceiling.
|
||||||
|
|
||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const ioClient = require('socket.io-client');
|
||||||
|
|
||||||
|
const PORT = 3983;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}`;
|
||||||
|
const DATA_DIR = path.join(os.tmpdir(), 'st-thr-int-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
const LOG = path.join(os.tmpdir(), 'st-thr-int-' + crypto.randomBytes(4).toString('hex') + '.log');
|
||||||
|
let proc;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const logFd = fs.openSync(LOG, 'w');
|
||||||
|
proc = spawn('node', ['server.js'], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
env: {
|
||||||
|
...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test',
|
||||||
|
// warm-up left at default (30s) so the whole test runs in the cold-start window
|
||||||
|
RECONNECT_HARD_CEILING: '8',
|
||||||
|
RECONNECT_WINDOW_MS: '5000',
|
||||||
|
RECONNECT_BASE_MAX: '3',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', logFd, logFd],
|
||||||
|
});
|
||||||
|
let up = false;
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
||||||
|
|
||||||
|
// Provision a brand-new device via a UNIQUE pairing code -> returns {device_id, device_token}.
|
||||||
|
function provision() {
|
||||||
|
const code = String(crypto.randomInt(100000, 1000000));
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
||||||
|
sock.on('connect', () => sock.emit('device:register', { pairing_code: code }));
|
||||||
|
sock.on('device:registered', (d) => { try { sock.close(); } catch { /* */ } resolve({ id: d.device_id, token: d.device_token }); });
|
||||||
|
setTimeout(() => { try { sock.close(); } catch { /* */ } resolve(null); }, 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One genuine reconnect (new socket). Resolves {registered, throttled}.
|
||||||
|
function reconnect(dev) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
||||||
|
let done = false;
|
||||||
|
const finish = (r) => { if (done) return; done = true; try { sock.close(); } catch { /* */ } resolve(r); };
|
||||||
|
sock.on('connect', () => sock.emit('device:register', { device_id: dev.id, device_token: dev.token, device_info: { app_version: 'test' } }));
|
||||||
|
sock.on('device:registered', () => finish({ registered: true, throttled: false }));
|
||||||
|
sock.on('device:throttled', () => finish({ registered: false, throttled: true }));
|
||||||
|
setTimeout(() => finish({ registered: false, throttled: false }), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GATE: full-fleet reconnect right after restart throttles NO healthy device', async () => {
|
||||||
|
// 12 distinct devices, each reconnecting twice in quick succession — a deploy-time
|
||||||
|
// herd. The loop is transiently busy, but per-device keying means none is flagged.
|
||||||
|
const fleet = [];
|
||||||
|
for (let i = 0; i < 12; i++) { const d = await provision(); assert.ok(d, 'device provisioned'); fleet.push(d); }
|
||||||
|
|
||||||
|
let registered = 0, throttled = 0;
|
||||||
|
// two reconnect rounds across the whole fleet
|
||||||
|
for (let round = 0; round < 2; round++) {
|
||||||
|
const results = await Promise.all(fleet.map(reconnect));
|
||||||
|
for (const r of results) { if (r.registered) registered++; if (r.throttled) throttled++; }
|
||||||
|
}
|
||||||
|
assert.equal(throttled, 0, 'NO healthy fleet device may be throttled at cold start');
|
||||||
|
assert.equal(registered, 24, 'every fleet reconnect registered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single device storming IS throttled (backoff engages)', async () => {
|
||||||
|
const dev = await provision();
|
||||||
|
assert.ok(dev);
|
||||||
|
let registered = 0, throttled = 0;
|
||||||
|
// 12 sequential reconnects within the 5s window -> exceeds the hard ceiling (8)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const r = await reconnect(dev);
|
||||||
|
if (r.registered) registered++;
|
||||||
|
if (r.throttled) throttled++;
|
||||||
|
}
|
||||||
|
assert.ok(throttled >= 1, `storming device must be throttled (got ${throttled} throttle(s))`);
|
||||||
|
assert.ok(registered < 12, `not all storm reconnects should succeed (got ${registered}/12)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neighbor isolation: a healthy device is unaffected while another storms', async () => {
|
||||||
|
const stormer = await provision();
|
||||||
|
const neighbor = await provision();
|
||||||
|
assert.ok(stormer && neighbor);
|
||||||
|
// storm the stormer hard
|
||||||
|
for (let i = 0; i < 12; i++) await reconnect(stormer);
|
||||||
|
// neighbor reconnects normally a couple of times -> must still register
|
||||||
|
const a = await reconnect(neighbor);
|
||||||
|
const b = await reconnect(neighbor);
|
||||||
|
assert.ok(a.registered && b.registered, 'neighbor must register normally while another device storms');
|
||||||
|
assert.ok(!a.throttled && !b.throttled, 'neighbor must not be throttled by another device');
|
||||||
|
});
|
||||||
98
server/test/reconnect-throttle.test.js
Normal file
98
server/test/reconnect-throttle.test.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #142 step 3 — deterministic unit tests for the per-device reconnect throttle.
|
||||||
|
// Pure logic with injected `now` / band; isolate the DB before require (the module
|
||||||
|
// pulls in services/loop-lag -> db/database which initialises a DB on load).
|
||||||
|
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-thr-unit-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
|
||||||
|
const { test, beforeEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const throttle = require('../lib/reconnect-throttle');
|
||||||
|
|
||||||
|
// config defaults: window=10000, baseMax=5, hardCeiling=20, baseBackoff=1000,
|
||||||
|
// maxBackoff=60000, releaseMs=30000, warmup=30000, elevMult=2, critMult=4.
|
||||||
|
const T0 = 1_000_000; // arbitrary epoch-ms origin for the warm-up clock
|
||||||
|
const POST = T0 + 40_000; // safely past the 30s warm-up
|
||||||
|
const WARM = T0 + 1_000; // inside the warm-up window
|
||||||
|
|
||||||
|
beforeEach(() => throttle.__resetForTest({ startedAt: T0 }));
|
||||||
|
|
||||||
|
test('healthy device is never throttled (<= baseMax genuine reconnects)', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const v = throttle.check('A', POST + i, 'normal');
|
||||||
|
assert.ok(v.allow, `reconnect ${i + 1} (<=baseMax) must be allowed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a per-device storm IS throttled and the backoff GROWS (tighten fast)', () => {
|
||||||
|
let v;
|
||||||
|
for (let i = 0; i < 5; i++) v = throttle.check('B', POST + i, 'normal'); // 5 allowed
|
||||||
|
v = throttle.check('B', POST + 5, 'normal'); // 6th -> flagged
|
||||||
|
assert.equal(v.allow, false);
|
||||||
|
assert.equal(v.reason, 'rate');
|
||||||
|
assert.equal(v.observed, 6);
|
||||||
|
assert.equal(v.allowed, 5);
|
||||||
|
const b1 = v.retryAfterMs;
|
||||||
|
// keep hammering while blocked -> escalate, longer backoff each time
|
||||||
|
const b2 = throttle.check('B', POST + 6, 'normal').retryAfterMs;
|
||||||
|
const b3 = throttle.check('B', POST + 7, 'normal').retryAfterMs;
|
||||||
|
assert.ok(b2 > b1 && b3 > b2, `backoff must grow: ${b1} < ${b2} < ${b3}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lag band multiplies an already-flagged device\'s backoff (critical > normal)', () => {
|
||||||
|
let v;
|
||||||
|
for (let i = 0; i < 5; i++) throttle.check('N', POST + i, 'normal');
|
||||||
|
v = throttle.check('N', POST + 5, 'normal');
|
||||||
|
const normalBackoff = v.retryAfterMs;
|
||||||
|
|
||||||
|
throttle.__resetForTest({ startedAt: T0 });
|
||||||
|
for (let i = 0; i < 5; i++) throttle.check('C', POST + i, 'critical');
|
||||||
|
v = throttle.check('C', POST + 5, 'critical');
|
||||||
|
assert.ok(v.retryAfterMs > normalBackoff, `critical backoff ${v.retryAfterMs} > normal ${normalBackoff}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a healthy device is NOT throttled even when the band is critical (lag never gates the healthy)', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const v = throttle.check('H', POST + i, 'critical');
|
||||||
|
assert.ok(v.allow, 'healthy device stays allowed regardless of band');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('COLD START: during warm-up, moderate flapping (>baseMax, <ceiling) is NOT throttled', () => {
|
||||||
|
for (let i = 0; i < 12; i++) { // 12 > baseMax(5) but < hardCeiling(20)
|
||||||
|
const v = throttle.check('W', WARM + i, 'critical'); // band forced normal in warm-up anyway
|
||||||
|
assert.ok(v.allow, `warm-up reconnect ${i + 1} must be lenient`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HARD CEILING is enforced even during warm-up (slow-ramp cannot train through)', () => {
|
||||||
|
let v;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
v = throttle.check('K', WARM + i, 'normal');
|
||||||
|
assert.ok(v.allow, `warm-up reconnect ${i + 1} (<=ceiling) allowed`);
|
||||||
|
}
|
||||||
|
v = throttle.check('K', WARM + 20, 'normal'); // 21st -> over ceiling(20)
|
||||||
|
assert.equal(v.allow, false);
|
||||||
|
assert.equal(v.reason, 'hard-ceiling');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neighbor isolation: one device storming does not throttle another', () => {
|
||||||
|
for (let i = 0; i < 10; i++) throttle.check('STORM', POST + i, 'normal'); // STORM gets throttled
|
||||||
|
const v = throttle.check('NEIGHBOR', POST + 11, 'normal');
|
||||||
|
assert.ok(v.allow, 'a different device must be unaffected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release slow: escalation level decays after a calm period', () => {
|
||||||
|
let v;
|
||||||
|
for (let i = 0; i < 6; i++) v = throttle.check('R', POST + i, 'normal'); // flagged, level 1
|
||||||
|
assert.ok(v.level >= 1);
|
||||||
|
const peak = v.level;
|
||||||
|
// a calm reconnect well past the window AND past releaseMs(30000)
|
||||||
|
v = throttle.check('R', POST + 6 + 40_000, 'normal');
|
||||||
|
assert.ok(v.allow, 'calm reconnect after the storm is allowed');
|
||||||
|
assert.ok(v.level < peak, `level decays after calm: ${v.level} < ${peak}`);
|
||||||
|
});
|
||||||
48
server/test/status-log-prune.test.js
Normal file
48
server/test/status-log-prune.test.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #142 step 4 — global device_status_log retention sweep. Deterministic, in-process
|
||||||
|
// (no server/port). Isolate the DB and set retention BEFORE requiring the module
|
||||||
|
// (config reads env at load; database.js initialises a DB on load).
|
||||||
|
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-statusprune-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
process.env.STATUS_LOG_RETENTION_DAYS = '2';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { db, pruneStatusLog } = require('../db/database');
|
||||||
|
|
||||||
|
test('global sweep deletes rows older than retention across ALL devices, keeps recent', () => {
|
||||||
|
db.exec('DELETE FROM device_status_log'); // clean slate
|
||||||
|
const old = db.prepare("INSERT INTO device_status_log (device_id, status, timestamp) VALUES (?, ?, strftime('%s','now') - ?)");
|
||||||
|
|
||||||
|
// 5 days old (> 2d retention): an active device, a device NOT in the devices
|
||||||
|
// table (removed/idle — what the per-device insert-time prune never revisits),
|
||||||
|
// and the heartbeat offline_timeout status that bypasses logDeviceStatus.
|
||||||
|
old.run('live-dev', 'online', 5 * 86400);
|
||||||
|
old.run('removed-idle-dev', 'offline', 5 * 86400);
|
||||||
|
old.run('hb-dev', 'offline_timeout', 5 * 86400);
|
||||||
|
// recent (< retention): must survive, regardless of device existence / status.
|
||||||
|
old.run('live-dev', 'online', 0);
|
||||||
|
old.run('hb-dev', 'offline_timeout', 3600);
|
||||||
|
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) c FROM device_status_log').get().c, 5, 'seeded 5 rows');
|
||||||
|
|
||||||
|
const deleted = pruneStatusLog();
|
||||||
|
assert.equal(deleted, 3, 'the 3 over-retention rows pruned (incl. removed-idle + offline_timeout paths)');
|
||||||
|
|
||||||
|
const remaining = db.prepare('SELECT device_id, status FROM device_status_log ORDER BY device_id').all();
|
||||||
|
assert.equal(remaining.length, 2);
|
||||||
|
// both survivors are the recent rows; no old row of any device/status survived
|
||||||
|
assert.deepEqual(remaining.map(r => r.device_id).sort(), ['hb-dev', 'live-dev']);
|
||||||
|
const oldestNow = db.prepare("SELECT MIN(timestamp) m FROM device_status_log").get().m;
|
||||||
|
const cutoff = Math.floor(Date.now() / 1000) - 2 * 86400;
|
||||||
|
assert.ok(oldestNow >= cutoff, 'no surviving row is older than the retention cutoff');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sweep is safe and idempotent on an empty/already-clean table', () => {
|
||||||
|
db.exec('DELETE FROM device_status_log');
|
||||||
|
assert.equal(pruneStatusLog(), 0, 'nothing to delete -> 0, no throw');
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const heartbeat = require('../services/heartbeat');
|
const heartbeat = require('../services/heartbeat');
|
||||||
const commandQueue = require('../lib/command-queue');
|
const commandQueue = require('../lib/command-queue');
|
||||||
|
const reconnectThrottle = require('../lib/reconnect-throttle');
|
||||||
|
|
||||||
// Debounce window for marking a device offline on socket disconnect. Brief
|
// Debounce window for marking a device offline on socket disconnect. Brief
|
||||||
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
|
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
|
||||||
|
|
@ -27,6 +28,12 @@ const OFFLINE_DEBOUNCE_MS = 5000;
|
||||||
// event is still forwarded every time, so the UI is unaffected. In-memory only.
|
// event is still forwarded every time, so the UI is unaffected. In-memory only.
|
||||||
const lastPlayLogAt = new Map();
|
const lastPlayLogAt = new Map();
|
||||||
const PLAY_LOG_MIN_GAP_MS = 2000;
|
const PLAY_LOG_MIN_GAP_MS = 2000;
|
||||||
|
|
||||||
|
// #142 content-ack dedup. An older app can spam "content <id>: ready" for the same
|
||||||
|
// item; each was logged + emitted individually (secondary load). Suppress identical
|
||||||
|
// (device_id, content_id, status) reports within config.contentAckDedupMs. A status
|
||||||
|
// CHANGE has a different key and passes immediately. In-memory; resets on restart.
|
||||||
|
const lastContentAck = new Map();
|
||||||
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
|
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
|
||||||
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
|
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
|
||||||
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
|
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
|
||||||
|
|
@ -353,6 +360,23 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #142: per-device reconnect throttle. Only GENUINE reconnects (a new
|
||||||
|
// socket) count — same-socket playlist refreshes (isPlaylistRefresh) are
|
||||||
|
// exempt. This runs BEFORE the heavy register work (DB writes, playlist
|
||||||
|
// build) so a single flapping device cannot saturate the event loop. The
|
||||||
|
// verdict is per-device; global lag only scales an already-flagged
|
||||||
|
// device's backoff, never gates a healthy one.
|
||||||
|
if (!isPlaylistRefresh) {
|
||||||
|
const verdict = reconnectThrottle.check(device_id);
|
||||||
|
if (!verdict.allow) {
|
||||||
|
console.warn(`[throttle] device ${device_id} reconnect throttled: reason=${verdict.reason} band=${verdict.band} observed=${verdict.observed}/${verdict.allowed} per ${config.reconnectWindowMs}ms -> backoff ${verdict.retryAfterMs}ms (level ${verdict.level})`);
|
||||||
|
socket.emit('device:throttled', { retry_after_ms: verdict.retryAfterMs, reason: 'reconnect_rate' });
|
||||||
|
// nextTick disconnect so the throttle notice flushes first.
|
||||||
|
process.nextTick(() => { try { socket.disconnect(true); } catch (_) { /* */ } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
currentDeviceId = device_id;
|
currentDeviceId = device_id;
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
// Cancel any pending offline timer - device is back in the grace window
|
// Cancel any pending offline timer - device is back in the grace window
|
||||||
|
|
@ -372,8 +396,12 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (device_info) {
|
if (device_info) {
|
||||||
db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ? WHERE id = ?')
|
db.prepare(`UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ?,
|
||||||
.run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_info.render_width ?? null, device_info.render_height ?? null, device_id);
|
ota_status = ?, ota_target_version = ?, ota_attempts = ?, ota_updated_at = strftime('%s','now') WHERE id = ?`)
|
||||||
|
.run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_info.render_width ?? null, device_info.render_height ?? null,
|
||||||
|
// #139 Phase 2: older APKs don't send these — default to a clean 'none' state.
|
||||||
|
device_info.ota_status ?? 'none', device_info.ota_target_version ?? null, device_info.ota_attempts ?? 0,
|
||||||
|
device_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeat.registerConnection(device_id, socket.id);
|
heartbeat.registerConnection(device_id, socket.id);
|
||||||
|
|
@ -557,6 +585,13 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
if (!requireDeviceAuth()) return;
|
if (!requireDeviceAuth()) return;
|
||||||
const { device_id, content_id, status } = data;
|
const { device_id, content_id, status } = data;
|
||||||
if (device_id !== currentDeviceId) return;
|
if (device_id !== currentDeviceId) return;
|
||||||
|
// #142: drop repeats of the same (device, content, status) within the dedup
|
||||||
|
// window. Only a change (new content/status) or a report after the window
|
||||||
|
// logs+emits, so a device spamming the same "ready" can't add load.
|
||||||
|
const ackKey = `${device_id}|${content_id}|${status}`;
|
||||||
|
const nowAck = Date.now();
|
||||||
|
if (nowAck - (lastContentAck.get(ackKey) || 0) < config.contentAckDedupMs) return;
|
||||||
|
lastContentAck.set(ackKey, nowAck);
|
||||||
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
||||||
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:content-ack', { device_id, content_id, status });
|
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:content-ack', { device_id, content_id, status });
|
||||||
});
|
});
|
||||||
|
|
@ -585,6 +620,20 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #139 Phase 2 (Option B): event-driven OTA status. The device announces a status TRANSITION
|
||||||
|
// ('manual_update_required' on enter-backoff, 'none' on clear) so the dashboard badge updates
|
||||||
|
// promptly without waiting for a reconnect. The register path still persists these fields too
|
||||||
|
// (the reconnect backstop if a transition event is missed). Same columns + ?? defaults.
|
||||||
|
socket.on('device:ota-status', (data) => {
|
||||||
|
if (!requireDeviceAuth()) return;
|
||||||
|
const { device_id, ota_status, ota_target_version, ota_attempts } = data || {};
|
||||||
|
// Unknown / forged / mismatched id -> no-op. WHERE id = ? also makes an unregistered id a
|
||||||
|
// 0-row update (never throws), so a stray event can't error the socket.
|
||||||
|
if (!device_id || device_id !== currentDeviceId) return;
|
||||||
|
db.prepare("UPDATE devices SET ota_status = ?, ota_target_version = ?, ota_attempts = ?, ota_updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
|
.run(ota_status ?? 'none', ota_target_version ?? null, ota_attempts ?? 0, device_id);
|
||||||
|
});
|
||||||
|
|
||||||
// Play event logging (proof-of-play)
|
// Play event logging (proof-of-play)
|
||||||
socket.on('device:play-event', (data) => {
|
socket.on('device:play-event', (data) => {
|
||||||
if (!requireDeviceAuth()) return;
|
if (!requireDeviceAuth()) return;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
|
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
|
||||||
id="http://screentinker.com/player" version="1.9.1" viewmodes="maximized">
|
id="http://screentinker.com/player" version="1.9.2" viewmodes="maximized">
|
||||||
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
|
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
|
||||||
<tizen:profile name="tv"/>
|
<tizen:profile name="tv"/>
|
||||||
<name>ScreenTinker</name>
|
<name>ScreenTinker</name>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue