Make the Tizen and BrightSign players do what they say they do

Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.

TIZEN

The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.

Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.

Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.

The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.

BRIGHTSIGN

FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.

The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.

offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.

display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.

roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.

dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.

Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.

TESTS

The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-06 10:51:15 -05:00
parent 2237edab12
commit 4e1de8ec0e
10 changed files with 1134 additions and 115 deletions

View file

@ -126,10 +126,18 @@ End Function
Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object) As Object Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object) As Object
config = { config = {
url: url url: url
nodejs_enabled: true ' Node runtime inside the widget ' THIS is what gates require("@brightsign/*"). Without it the bridge silently degrades to
brightsign_js_objects_enabled: true ' REQUIRED for require("@brightsign/*") — without ' no-ops and the player loses identity AND restart delegation. ("BrightSign modules are
' this the bridge silently degrades to no-ops and ' actually part of the firmware, but in terms of usage they are identical to other Node.js
' the player loses identity AND restart delegation ' modules" — so no Node runtime means no modules.)
nodejs_enabled: true
' NOT what gates require(). This flag enables the LEGACY GLOBAL objects — BSDeviceInfo,
' BSMessagePort and friends — which this bridge does not use; BrightSign's own cookbook
' examples call require("@brightsign/bt") with nodejs_enabled alone. Kept set because
' several of their samples set both and it costs nothing, but the comment that used to sit
' here credited it with holding the whole bridge up, which would send the next person
' debugging a dead bridge to exactly the wrong line.
brightsign_js_objects_enabled: true
javascript_enabled: true javascript_enabled: true
security_params: { websecurity: true } security_params: { websecurity: true }
hwz_default: "on" ' hardware z-order — video on its own plane hwz_default: "on" ' hardware z-order — video on its own plane
@ -139,7 +147,10 @@ Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object
' persist to. The XT245 on alpha exposes navigator.serviceWorker and then refuses to ' persist to. The XT245 on alpha exposes navigator.serviceWorker and then refuses to
' register one, which is exactly what a widget with no usable storage would do. ' register one, which is exactly what a widget with no usable storage would do.
storage_path: StorageRoot() + "/cache" ' local storage, on the volume we booted from storage_path: StorageRoot() + "/cache" ' local storage, on the volume we booted from
storage_quota: "1073741824" ' 1GB, as a STRING — service-worker offline cache ' 1GB, as a DOUBLE. The docs are explicit: "A BrightScript integer is only guaranteed to be
' able to represent a count of bytes up to 2GB so avoid using integers... Use float or double
' instead... (string can also be used but is not recommended)". This was a string.
storage_quota: 1073741824.0
port: port port: port
mouse_enabled: false mouse_enabled: false
} }
@ -289,8 +300,15 @@ Sub SetOrientation(widget As Object, o As String)
' page never learned to fall back. Rotation lives on SetScreenModes(), whose per-screen config ' page never learned to fall back. Rotation lives on SetScreenModes(), whose per-screen config
' carries a `transform` of normal|90|180|270 and rotates EVERYTHING including the video plane. ' carries a `transform` of normal|90|180|270 and rotates EVERYTHING including the video plane.
' Implemented in BOS 9.0.15+; an older player simply has no method here and is told so. ' Implemented in BOS 9.0.15+; an older player simply has no method here and is told so.
' FindMemberFunction is the guard BrightSign's own scripts use for a method that may not exist ' FindMemberFunction is the documented way to ask whether a method exists on this OS version —
' on this OS version — safer than naming a member directly, which would attempt the call. ' safer than naming a member directly, which would attempt the call. It is itself feature-gated
' (see HasFindMember), and a player that cannot ask cannot be told the answer is yes: rotation
' is refused rather than risked, and the page keeps its CSS fallback.
if not HasFindMember() then
print "[st] orientation: cannot probe this OS for SetScreenModes — keeping the CSS fallback"
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "cannot probe this OS version" })
return
end if
if FindMemberFunction(vm, "GetScreenModes") = invalid or FindMemberFunction(vm, "SetScreenModes") = invalid then if FindMemberFunction(vm, "GetScreenModes") = invalid or FindMemberFunction(vm, "SetScreenModes") = invalid then
print "[st] orientation: this OS has no SetScreenModes — the page keeps its CSS fallback" print "[st] orientation: this OS has no SetScreenModes — the page keeps its CSS fallback"
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "SetScreenModes unavailable" }) widget.PostJSMessage({ type: "orientation-result", ok: false, error: "SetScreenModes unavailable" })
@ -342,6 +360,27 @@ End Sub
' interface is physically dead, and the DWS still refuses the capture — internal flash is not ' interface is physically dead, and the DWS still refuses the capture — internal flash is not
' "primary storage" as that endpoint means it. Counting it would re-create the exact lie this ' "primary storage" as that endpoint means it. Counting it would re-create the exact lie this
' probe exists to prevent. ' probe exists to prevent.
' Drop a trailing "/" from a drive specifier. roStorageHotplug.GetStorages() answers with one
' ("SSD:/"), the rest of this script speaks the bare form ("SSD:"), and roStorageInfo takes either.
Function TrimDrive(raw As String) As String
n% = Len(raw)
if n% > 0 and Mid(raw, n%, 1) = "/" then return Left(raw, n% - 1)
return raw
End Function
' Turn a drive specifier into the one GetStorageStatus() actually accepts.
'
' GetStorages() -> ["USB1:/", "SD:/", "SD2:/", "SSD:/", "Flash:/"]
' GetStorageStatus() understands "USB:", "SD:", "SSD:", "SD2:/", "Flash:" and is documented as
' UNRELIABLE for "USBn:". So: drop the trailing slash, and collapse any USBn to a bare "USB:".
' roStorageInfo, by contrast, is documented for the NUMBERED form — so the two callers get
' different strings and the numbering is only thrown away where it does harm.
Function StatusDrive(raw As String) As String
d$ = TrimDrive(raw)
if LCase(Left(d$, 3)) = "usb" then return "USB:"
return d$
End Function
Function StorageProbe() As Object Function StorageProbe() As Object
result = { present: false, volume: "", free_mb: 0, total_mb: 0 } result = { present: false, volume: "", free_mb: 0, total_mb: 0 }
@ -352,14 +391,26 @@ Function StorageProbe() As Object
' Ask the platform which volumes exist rather than guessing; the static list is the fallback for ' Ask the platform which volumes exist rather than guessing; the static list is the fallback for
' an OS without the enumerator. Same shape BrightSign's own boilerplate uses. ' an OS without the enumerator. Same shape BrightSign's own boilerplate uses.
volumes = ["SSD:", "SD:", "SD2:", "USB:"] volumes = ["SSD:", "SD:", "SD2:", "USB:"]
if hp <> invalid and FindMemberFunction(hp, "GetStorages") <> invalid then ' Feature-gated (see HasFindMember). A player that cannot be probed simply keeps the static list,
found = hp.GetStorages() ' which is the answer the enumerator would have given anyway on every model we ship.
if found <> invalid and found.Count() > 0 then volumes = found if hp <> invalid and HasFindMember() then
if FindMemberFunction(hp, "GetStorages") <> invalid then
found = hp.GetStorages()
if found <> invalid and found.Count() > 0 then volumes = found
end if
end if end if
for each v in volumes for each raw in volumes
' ⚠️ GetStorages() answers in a DIFFERENT vocabulary to the one GetStorageStatus() accepts:
' it returns ["USB1:/", "SD:/", "SD2:/", "SSD:/", "Flash:/"] — trailing slash, and USB
' NUMBERED. GetStorageStatus() is documented as UNRELIABLE when called with a "USBn:"
' parameter and understands "USB:", "SD:", "SSD:", "SD2:/", "Flash:". So handing the
' enumerator's own output straight back to it re-creates exactly the bug the static list was
' written to avoid — silently, and only on the OS versions that HAVE the enumerator, which is
' why the static fallback looked correct in testing.
v = TrimDrive(raw)
mounted = false mounted = false
if hp <> invalid then if hp <> invalid then
st = hp.GetStorageStatus(v) st = hp.GetStorageStatus(StatusDrive(raw))
if st <> invalid and st.mounted then mounted = true if st <> invalid and st.mounted then mounted = true
end if end if
@ -408,6 +459,34 @@ Function FullScreenRect() As Object
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY()) return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
End Function End Function
' Where the SECOND output lives inside the combined canvas, or invalid on a single-output player.
'
' GetResX/GetResY only ever describe output 1, so they cannot answer this. The per-screen
' configuration can: each entry carries display_x/display_y (its origin within the canvas built by
' SetScreenModes) and `enabled`. A widget placed at that origin paints that output; there is no
' other mechanism, because roHtmlWidget has no output selector.
'
' Returns invalid unless a SECOND, ENABLED screen genuinely exists — the caller then stays
' single-screen and says so, rather than stacking two widgets on output one. The docs warn that
' GetScreenModes on a player with unconnected outputs "won't get a valid return" for them, so an
' entry that does not describe a real screen is treated as absent.
Function SecondScreenRect() As Object
if not HasFindMember() then return invalid
vm = CreateObject("roVideoMode")
if vm = invalid then return invalid
if FindMemberFunction(vm, "GetScreenModes") = invalid then return invalid
configs = vm.GetScreenModes()
if configs = invalid or configs.Count() < 2 then return invalid
s = configs[1]
if s = invalid then return invalid
if s.enabled <> invalid and s.enabled = false then return invalid
if s.display_x = invalid or s.display_y = invalid then return invalid
return CreateObject("roRectangle", s.display_x, s.display_y, vm.GetResX(), vm.GetResY())
End Function
'=== self-update ============================================================================ '=== self-update ============================================================================
' '
' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the ' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the
@ -437,6 +516,20 @@ Function PackageVersion() As String
return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand) return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand)
End Function End Function
' Can this player use FindMemberFunction() at all?
'
' ⚠️ It is NOT unconditionally available: "It is only available if
' roDeviceInfo.HasFeature("FindMemberFunction") returns true." Calling it on a player without the
' feature is a runtime error — and both call sites are reached FROM THE EVENT LOOP (the capability
' probe on every boot, the storage figures in host telemetry every 60 seconds), so on such a player
' the host script would die within a minute of starting and take the display with it. The guard it
' was being used AS is the thing that needed guarding.
Function HasFindMember() As Boolean
di = CreateObject("roDeviceInfo")
if di = invalid then return false
return di.HasFeature("FindMemberFunction")
End Function
' Does [path] exist? ' Does [path] exist?
' '
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their ' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
@ -559,6 +652,17 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
return return
end if end if
' Guard the manifest HERE, at the call site, because that is the only place a guard can help.
' VerifyPackage takes `As String` / `As Integer` parameters, and a missing key is `invalid`:
' handing invalid to a typed parameter is a runtime error raised at the CALL, before a single
' line inside the function runs. The check inside VerifyPackage reads like it covers this and
' cannot — the script would already have aborted, from inside the event loop, taking playback
' down with it. url is checked for the same reason (it is concatenated into a `As String`).
if manifest.url = invalid or manifest.sha256 = invalid or manifest.size = invalid then
print "[st-update] manifest says download but is missing url/sha256/size — ignoring it"
return
end if
print "[st-update] downloading package "; manifest.version print "[st-update] downloading package "; manifest.version
' Any earlier partial is deleted first: resuming into an existing file would concatenate two ' Any earlier partial is deleted first: resuming into an existing file would concatenate two
@ -783,17 +887,37 @@ Sub Main()
widget = MakeWidget(PlayerUrl(cfg, 1), rect, port, cfg) widget = MakeWidget(PlayerUrl(cfg, 1), rect, port, cfg)
widget.Show() widget.Show()
' The boot story, delivered late but delivered. Everything above here happened with no page to ' NOT flushed here. Show() only creates the widget — the page has not been fetched, let alone
' talk to, which is exactly the window in which the interesting failures live. ' run st-bridge.js, so there is nothing on the other end of PostJSMessage yet and every line
FlushLog(widget, boot) ' would go into the void. Buffered instead until the page says hello (its `probe` message,
SendHostTelemetry(widget, cfg) ' which st-bridge.js posts as soon as it loads), which is the whole reason the buffer exists.
' The same window ate SendHostTelemetry; telemetry repeats every 60s so it self-healed and the
' boot report — the one that only ever happens once — did not.
widget2 = invalid widget2 = invalid
if dual then if dual then
screen2 = 2 ' ⚠️ There is NO per-widget output selector. roHtmlWidget takes a rectangle and nothing else:
if cfg.output_mode = "clone" then screen2 = 1 ' its init parameters have no `screen`/`output` key, and neither does the JavaScript
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect, port, cfg) ' HtmlWidgetParams. A second output is addressed by BUILDING ONE TALL CANVAS with
if widget2 <> invalid then widget2.Show() ' SetScreenModes (display_x/display_y stack the outputs) and then placing the second widget
' at that offset inside it.
'
' Which means the previous version could not work: it passed the SAME full-screen rect for
' both widgets, so widget 2 was composited directly on top of widget 1 on output ONE — two
' players fighting over one screen while the second output stayed dark. "dual" and "clone"
' were configuration options that made the display worse and reported nothing.
rect2 = SecondScreenRect()
if rect2 = invalid then
' Refused rather than guessed. Multi-output is documented for the XC2055 (two) and
' XC4055 (four); the XT line has HDMI IN and HDMI OUT, which the series blurb describes
' as "dual HDMI" and which is not a second output at all.
LogTo(boot, "boot", "output_mode=" + cfg.output_mode + " but this player exposes one output — staying single-screen")
HostEvent(widget, "app_error", "output-mode", "dual/clone requested; this player has a single output")
else
screen2 = 2
if cfg.output_mode = "clone" then screen2 = 1
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect2, port, cfg)
if widget2 <> invalid then widget2.Show()
end if
end if end if
retries = 0 retries = 0
@ -894,6 +1018,11 @@ Sub Main()
' Asked once during boot, before the player registers: the answer decides which ' Asked once during boot, before the player registers: the answer decides which
' controls the dashboard is allowed to offer for this display. ' controls the dashboard is allowed to offer for this display.
SendProbeResult(widget) SendProbeResult(widget)
' ...and this is the first PROOF that a page is listening, so it is the earliest
' moment the buffered boot story can actually be delivered. st-bridge.js holds it
' until the player's socket is up, so late here is still in time.
FlushLog(widget, boot)
SendHostTelemetry(widget, cfg)
else if m.type = "set-orientation" then else if m.type = "set-orientation" then
if m.orientation <> invalid then SetOrientation(widget, m.orientation) if m.orientation <> invalid then SetOrientation(widget, m.orientation)

View file

@ -35,7 +35,17 @@
var MessagePortClass = tryRequire('@brightsign/messageport'); var MessagePortClass = tryRequire('@brightsign/messageport');
var RegistryClass = tryRequire('@brightsign/registry'); var RegistryClass = tryRequire('@brightsign/registry');
var DeviceInfoClass = tryRequire('@brightsign/deviceinfo'); var DeviceInfoClass = tryRequire('@brightsign/deviceinfo');
var VideoOutputClass = tryRequire('@brightsign/videooutput'); /*
* @brightsign/videooutput does NOT set a video mode. Its surface is read-only plus power
* (getVideoResolution / getEdid / isAttached / setPowerSaveMode / setBackgroundColor); there is
* no setMode on it at all. Mode setting lives on @brightsign/videomodeconfiguration, whose
* setMode() returns a Promise<{restartRequired}>.
*
* The two were conflated here, and the cost was not a broken call the call was guarded it was
* a LIE: a widget with no host bridge declared display.resolution purely because videooutput
* resolved, and the dashboard grew a resolution control that could never do anything.
*/
var VideoModeConfigClass = tryRequire('@brightsign/videomodeconfiguration');
var CecClass = tryRequire('@brightsign/cec'); var CecClass = tryRequire('@brightsign/cec');
var port = null; var port = null;
@ -243,6 +253,65 @@
* describes the widget's cache quota, not the disk: a panel can report gigabytes free while the * describes the widget's cache quota, not the disk: a panel can report gigabytes free while the
* volume holding them is full, and only the host can tell the difference. * volume holding them is full, and only the host can tell the difference.
*/ */
/*
* Host diagnostics arrive BEFORE anyone is listening, and that is not an edge case it is the
* normal order of events and the whole reason they are worth carrying.
*
* The host buffers its pre-widget boot lines and posts them the moment the page says hello. The
* player, correctly, does not subscribe until its socket is connected, because a line forwarded
* before that has nowhere to go. Between those two facts every boot line was dropped: the host
* spoke into a page with no listener, and the listener arrived after the words had gone. The
* player's own comment says wiring earlier "would drop the host's boot report on the floor"
* which was true, and left the report on the floor anyway.
*
* So the bridge holds them. Messages land in these queues from the moment the file loads, and are
* replayed to each consumer as it registers. Bounded, because a host stuck in a reboot loop must
* not grow this without limit on a player that runs for months.
*/
var PENDING_MAX = 200;
var logSinks = [];
var eventSinks = [];
var pendingLogs = [];
var pendingEvents = [];
function drain(queue, fn) {
// Copied first: fn is free to register another sink, and iterating a live array while it is
// being appended to is how a replay turns into a loop.
var items = queue.slice();
for (var i = 0; i < items.length; i++) {
try { fn(items[i]); } catch (e) { /* one bad consumer must not eat the rest of the boot log */ }
}
}
function fanout(sinks, queue, payload) {
if (sinks.length === 0) {
if (queue.length < PENDING_MAX) queue.push(payload);
return;
}
for (var i = 0; i < sinks.length; i++) {
try { sinks[i](payload); } catch (e) { /* ignore */ }
}
}
listeners.push(function (msg) {
if (!msg) return;
if (msg.type === 'host-log') {
fanout(logSinks, pendingLogs, {
tag: String(msg.tag || 'host').slice(0, 64),
level: String(msg.level || 'i').slice(0, 8),
message: String(msg.message || '').slice(0, 2000)
});
return;
}
if (msg.type === 'host-event' && msg.event) {
fanout(eventSinks, pendingEvents, {
event: String(msg.event),
reason: String(msg.reason || '').slice(0, 64),
detail: String(msg.detail || '').slice(0, 500)
});
}
});
listeners.push(function (msg) { listeners.push(function (msg) {
if (msg && msg.type === 'host-telemetry') { if (msg && msg.type === 'host-telemetry') {
var keys = ['uptime_seconds', 'local_ip', 'model', 'os_version', 'video_mode', var keys = ['uptime_seconds', 'local_ip', 'model', 'os_version', 'video_mode',
@ -327,10 +396,21 @@
*/ */
add('playback.transitions'); add('playback.pip'); add('playback.transitions'); add('playback.pip');
// Service-worker content caching. The quota is configured in autorun.brs (storage_path + /*
// storage_quota); without a service worker there is no offline story at all. * Service-worker content caching and this platform does not have it.
*
* `navigator.serviceWorker` EXISTS on a BrightSign widget and is not usable: our XT245 on alpha
* passes this exact check, then never even fetches sw.js. Presence was therefore the one signal
* that could not distinguish "caches offline" from "cannot", and it answered yes to both the
* player advertised offline.cache to the whole fleet while being unable to hold a single byte
* through an outage. The web player already learned this (it waits for a worker that is in
* CONTROL, see declareCapabilities in server/player/index.html); this copy had not.
*
* A controller is proof, not a promise: something is actually intercepting this page's fetches.
*/
try { try {
if (global.navigator && global.navigator.serviceWorker) add('offline.cache'); var sw = global.navigator && global.navigator.serviceWorker;
if (sw && sw.controller) add('offline.cache');
} catch (e) { /* no SW in this widget */ } } catch (e) { /* no SW in this widget */ }
// ---- needs the host bridge -------------------------------------------------------------- // ---- needs the host bridge --------------------------------------------------------------
@ -342,8 +422,8 @@
add('system.reboot'); // RebootSystem add('system.reboot'); // RebootSystem
add('display.rotation'); // roVideoMode transform — the ONLY way video rotates here add('display.rotation'); // roVideoMode transform — the ONLY way video rotates here
add('display.resolution'); // roVideoMode SetMode add('display.resolution'); // roVideoMode SetMode
} else if (VideoOutputClass) { } else if (VideoModeConfigClass) {
// No host, but the JS video-output module resolved: resolution alone is still reachable. // No host, but the JS mode-configuration module resolved: resolution alone is still reachable.
add('display.resolution'); add('display.resolution');
} }
@ -420,7 +500,12 @@
serial: function () { serial: function () {
if (deviceInfo) { if (deviceInfo) {
try { try {
var s = deviceInfo.serialNumber || (deviceInfo.getDeviceUniqueId && deviceInfo.getDeviceUniqueId()); // `serialNumber` is the whole answer. There is no getDeviceUniqueId() on
// @brightsign/deviceinfo — that is the BrightScript roDeviceInfo method name, and
// BrightSign's own migration note maps it to this attribute. `deviceUniqueId` is the
// legacy BSDeviceInfo global's spelling, also an attribute rather than a call, and is
// read here only so a very old widget build still answers with something.
var s = deviceInfo.serialNumber || deviceInfo.deviceUniqueId;
if (s) return String(s); if (s) return String(s);
} catch (e) { /* fall through to the URL */ } } catch (e) { /* fall through to the URL */ }
} }
@ -556,10 +641,17 @@
}, },
setVideoMode: function (mode) { setVideoMode: function (mode) {
if (VideoOutputClass) { if (VideoModeConfigClass) {
try { try {
var vo = new VideoOutputClass(); var vmc = new VideoModeConfigClass();
if (vo && typeof vo.setMode === 'function') { vo.setMode(mode); return true; } if (vmc && typeof vmc.setMode === 'function') {
// Promise<{restartRequired}>. Nothing here awaits it — a mode change that restarts the
// application takes this page with it, so there is no "after" to report into. Rejection
// is swallowed rather than left as an unhandled rejection on a signage player.
var r = vmc.setMode(mode);
if (r && typeof r.catch === 'function') r.catch(function () {});
return true;
}
} catch (e) { /* fall back to the host */ } } catch (e) { /* fall back to the host */ }
} }
return post({ type: 'set-video-mode', mode: mode }); return post({ type: 'set-video-mode', mode: mode });
@ -663,26 +755,14 @@
*/ */
onHostLog: function (fn) { onHostLog: function (fn) {
if (typeof fn !== 'function') return; if (typeof fn !== 'function') return;
listeners.push(function (msg) { logSinks.push(fn);
if (!msg || msg.type !== 'host-log') return; drain(pendingLogs, fn);
fn({
tag: String(msg.tag || 'host').slice(0, 64),
level: String(msg.level || 'i').slice(0, 8),
message: String(msg.message || '').slice(0, 2000)
});
});
}, },
onHostEvent: function (fn) { onHostEvent: function (fn) {
if (typeof fn !== 'function') return; if (typeof fn !== 'function') return;
listeners.push(function (msg) { eventSinks.push(fn);
if (!msg || msg.type !== 'host-event' || !msg.event) return; drain(pendingEvents, fn);
fn({
event: String(msg.event),
reason: String(msg.reason || '').slice(0, 64),
detail: String(msg.detail || '').slice(0, 500)
});
});
}, },
/* /*

View file

@ -98,7 +98,12 @@ const BASELINE = {
'display.rotation', 'display.power', 'display.rotation', 'display.power',
'remote.input', 'remote.input',
'system.reboot', 'system.restart_player', 'system.reboot', 'system.restart_player',
'sync.clock', 'offline.cache', 'sync.clock',
// NOT offline.cache. A BrightSign widget EXPOSES navigator.serviceWorker and will not run one:
// our XT245 on alpha passes every presence check and then never even fetches sw.js. There is no
// other caching mechanism in the widget either — content comes off the network every time — so
// a legacy BrightSign that declares nothing has no offline story at all, and claiming one told
// the dashboard a panel would survive an outage that will in fact go blank.
], ],
// A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or // A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or
// capture anything outside its own document. // capture anything outside its own document.

View file

@ -44,6 +44,8 @@ const ROKU_ONLY = [
['roUnzip', 'use roBrightPackage — roUnzip is not the reader for a player package'], ['roUnzip', 'use roBrightPackage — roUnzip is not the reader for a player package'],
['roRegistryKey', 'use roRegistrySection'], ['roRegistryKey', 'use roRegistrySection'],
['roAssociativeArrayEx', 'plain roAssociativeArray'], ['roAssociativeArrayEx', 'plain roAssociativeArray'],
// roDeviceInfo.GetDeviceUniqueId() is real; a global of that name is not.
['roDataGramSocket', 'not a BrightSign object'],
]; ];
for (const [obj, advice] of ROKU_ONLY) { for (const [obj, advice] of ROKU_ONLY) {
@ -59,6 +61,13 @@ const BAD_METHODS = [
['PostFromStringWithRetry', 'roUrlTransfer has no retry variant; AsyncPostFromString + roUrlEvent is how a POST body is read'], ['PostFromStringWithRetry', 'roUrlTransfer has no retry variant; AsyncPostFromString + roUrlEvent is how a POST body is read'],
['.Final(', 'roMessageDigest-era API; roHashGenerator returns the digest from Hash()'], ['.Final(', 'roMessageDigest-era API; roHashGenerator returns the digest from Hash()'],
['OpenInputFile', 'no roFileSystem here; read with roByteArray.ReadFile'], ['OpenInputFile', 'no roFileSystem here; read with roByteArray.ReadFile'],
// Each of these reads as obviously-correct and is documented NOT to exist on BrightSign.
['SetMessagePort', 'the setter is SetPort(roMessagePort) — SetMessagePort is Roku'],
['.WaitEvent(', 'roMessagePort has WaitMessage(timeout_ms); WaitEvent is Roku'],
['.SetAlgorithm(', 'roHashGenerator takes its algorithm as a CONSTRUCTOR argument'],
['.VerifyPackage(', 'roBrightPackage has no VerifyPackage — hash the bytes yourself'],
['.UnpackAll(', 'roBrightPackage has Unpack(path) and UnpackFile(name, path)'],
['SetOrientation(vm', 'roVideoMode has no SetOrientation — rotation is SetScreenModes()[i].transform'],
]; ];
for (const [needle, advice] of BAD_METHODS) { for (const [needle, advice] of BAD_METHODS) {
@ -225,3 +234,150 @@ test('Str() is never applied to something that is already a string', () => {
} }
} }
}); });
/* ---------------------------------------------------------------------------------------------
* Rules added after a line-by-line pass against docs.brightsign.biz. Each one is a call that
* compiles, reads correctly, and is documented to do something other than what it looks like.
* ------------------------------------------------------------------------------------------- */
test('roVideoMode.SetOrientation does not exist — rotation is a SetScreenModes transform', () => {
// Zero occurrences of SetOrientation in the roVideoMode reference. Rotation has two documented
// routes and neither is a method on roVideoMode: the per-screen `transform` member of
// SetScreenModes ("normal"/"90"/"180"/"270"), or roHtmlWidget's `transform` init parameter
// ("identity"/"rot90"/"rot180"/"rot270") — note the two use DIFFERENT vocabularies.
for (const f of FILES) {
for (const m of code(f).matchAll(/(\w+)\.SetOrientation\s*\(/g)) {
assert.fail(`${f}: ${m[0]} — roVideoMode has no SetOrientation. Use SetScreenModes()[i].transform`);
}
}
});
test('FindMemberFunction is itself feature-gated before it is used as a guard', () => {
// "It is only available if roDeviceInfo.HasFeature("FindMemberFunction") returns true." Calling it
// on a player without the feature is a runtime error — and both call sites here are reached from
// the EVENT LOOP (the boot capability probe, and host telemetry every 60s), so on such a player
// the host would die within a minute and take the display with it. The guard needed guarding.
for (const f of FILES) {
const src = code(f);
if (!src.includes('FindMemberFunction(')) continue;
assert.match(src, /HasFeature\("FindMemberFunction"\)/,
`${f}: FindMemberFunction is only available when roDeviceInfo.HasFeature("FindMemberFunction") is true`);
// ...and every call must sit behind that check, not merely somewhere in the same file.
for (const m of src.matchAll(/^(?!.*HasFeature).*\bFindMemberFunction\(/gm)) {
const line = m[0];
assert.ok(/HasFindMember\(\)|HasFeature/.test(src.slice(Math.max(0, src.indexOf(line) - 400), src.indexOf(line))),
`${f}: this FindMemberFunction call is not behind a HasFeature guard:\n ${line.trim()}`);
}
}
});
test('GetStorageStatus is never handed a drive string straight from GetStorages()', () => {
// The two speak DIFFERENT vocabularies. GetStorages() answers ["USB1:/", "SD:/", "SD2:/", "SSD:/",
// "Flash:/"] — trailing slash, USB numbered — while GetStorageStatus() understands "USB:", "SD:",
// "SSD:", "SD2:/", "Flash:" and is documented as UNRELIABLE for "USBn:". Feeding the enumerator's
// output back in re-creates the bug the static fallback list exists to avoid, and only on the OS
// versions that HAVE the enumerator — so it looks fine in testing.
for (const f of FILES) {
const src = code(f);
if (!src.includes('GetStorageStatus')) continue;
for (const m of src.matchAll(/GetStorageStatus\(([^)]*)\)/g)) {
const arg = m[1].trim();
assert.ok(/^"/.test(arg) || /StatusDrive\(/.test(arg),
`${f}: ${m[0]} — normalise the drive first (StatusDrive) or pass a literal; GetStorages() speaks a different vocabulary`);
}
}
});
test('PostJSMessage keys are lowercase — BrightScript canonicalises them on the way to JS', () => {
// BrightScript associative-array keys created with object-literal syntax are case-INSENSITIVE and
// arrive lowercased on the JavaScript side. BrightSign's own sample sends
// `{serialNumber: ...}` and reads `msg["serialnumber"]`. A camelCase key here is therefore a field
// the bridge reads as undefined, silently, with no error anywhere.
for (const f of FILES) {
for (const m of code(f).matchAll(/PostJSMessage\(\{([\s\S]*?)\}\)/g)) {
for (const km of m[1].matchAll(/(?:^|[,\n])\s*([A-Za-z_]\w*)\s*:/g)) {
assert.equal(km[1], km[1].toLowerCase(),
`${f}: PostJSMessage key "${km[1]}" is not lowercase — it arrives in JavaScript lowercased and the reader sees undefined`);
}
}
}
});
test('PostJSMessage payloads are flat — nested associative arrays are not supported', () => {
// "This method does not support passing nested associative arrays." A nested value is dropped, so
// the message arrives looking well-formed and missing the part that mattered.
for (const f of FILES) {
for (const m of code(f).matchAll(/PostJSMessage\(\{([\s\S]*?)\}\)/g)) {
assert.ok(!/:\s*\{/.test(m[1]),
`${f}: PostJSMessage carries a nested associative array, which BrightSign drops:\n ${m[0].slice(0, 160)}`);
}
}
});
test('roUrlTransfer.SetPort takes a message port, never a TCP port number', () => {
// ifMessagePort.SetPort(port As roMessagePort). There is no integer overload — a TCP port belongs
// in the URL string. Passing a number here reads exactly like configuring a port and configures
// nothing.
for (const f of FILES) {
for (const m of code(f).matchAll(/\.SetPort\(([^)]*)\)/g)) {
assert.ok(!/^\d+$/.test(m[1].trim()),
`${f}: ${m[0]} — SetPort takes an roMessagePort; put a TCP port in the URL`);
}
}
});
test('roBrightPackage is constructed with a filename string, not an associative array', () => {
// "created with a filename parameter that specifies the name of the .zip file". A password goes
// through SetPassword() afterwards. An AA here is the Roku-shaped guess.
for (const f of FILES) {
for (const m of code(f).matchAll(/CreateObject\("roBrightPackage"\s*,\s*([^)]*)\)/g)) {
assert.ok(!m[1].trim().startsWith('{'),
`${f}: ${m[0]} — roBrightPackage takes a filename String; use SetPassword() for a password`);
}
}
});
test('the widget storage quota is not a string', () => {
// "A BrightScript integer is only guaranteed to be able to represent a count of bytes up to 2GB so
// avoid using integers... Use float or double instead... (string can also be used but is not
// recommended)."
const src = code('autorun.brs');
assert.ok(!/storage_quota:\s*"/.test(src), 'storage_quota should be a double, not a string');
assert.match(src, /storage_quota:\s*\d+\.\d/, 'storage_quota must be a double literal');
});
test('nodejs_enabled is what gates require("@brightsign/*")', () => {
// The bridge is entirely require()-based. brightsign_js_objects_enabled gates the LEGACY GLOBALS
// (BSDeviceInfo, BSMessagePort) and not the modules — BrightSign's own cookbook calls
// require("@brightsign/bt") with nodejs_enabled alone. Losing nodejs_enabled costs the player its
// identity and its restart delegation, silently.
const src = code('autorun.brs');
assert.match(src, /nodejs_enabled:\s*true/, 'without this there are no @brightsign modules at all');
});
test('a second widget is never given the first screen\'s rectangle', () => {
// roHtmlWidget has NO output selector — not in its init parameters, not in the JavaScript
// HtmlWidgetParams. A second output is addressed by building one tall canvas with SetScreenModes
// and placing the widget at that output's display_x/display_y. Reusing the full-screen rect puts
// both widgets on output ONE, on top of each other, while output two stays dark.
const src = code('autorun.brs');
if (!/widget2\s*=\s*MakeWidget/.test(src)) return;
// Split on TOP-LEVEL commas: the first argument is itself a call (PlayerUrl(cfg, screen2)), and a
// naive split lands on its inner comma and inspects the wrong argument — which is how the first
// draft of this rule passed against the very source it was written to reject.
const call = /widget2\s*=\s*MakeWidget\((.*)\)\s*$/m.exec(src);
assert.ok(call, 'could not find the second widget');
const args = [];
let depth = 0;
let cur = '';
for (const ch of call[1]) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) { args.push(cur.trim()); cur = ''; continue; }
cur += ch;
}
args.push(cur.trim());
assert.equal(args.length, 4, `unexpected MakeWidget arity: ${call[0]}`);
assert.notEqual(args[1], 'rect',
'the second widget must be positioned at the second output\'s canvas offset, not at rect');
});

View file

@ -26,7 +26,8 @@ const { CAP_SET } = require('../lib/player-capabilities');
* host - a message port exists (nodejs_enabled widget with a live autorun.brs) * host - a message port exists (nodejs_enabled widget with a live autorun.brs)
* probeRes - what the host answers the capability probe with (null = never answers) * probeRes - what the host answers the capability probe with (null = never answers)
* modules - which @brightsign/* modules resolve * modules - which @brightsign/* modules resolve
* sw - navigator.serviceWorker present * sw - navigator.serviceWorker present (the BrightSign reality: present, never usable)
* swControlled - ...and a worker actually controlling the page (what offline.cache really needs)
*/ */
function load(o = {}) { function load(o = {}) {
const opts = Object.assign( const opts = Object.assign(
@ -38,7 +39,13 @@ function load(o = {}) {
const sandbox = { const sandbox = {
console: { log() {}, warn() {}, error() {} }, console: { log() {}, warn() {}, error() {} },
navigator: Object.assign({ userAgent: 'BrightSign/9.0.189 (XT245) Chrome/120' }, opts.sw ? { serviceWorker: {} } : {}), // `serviceWorker` present but with NO controller is the real BrightSign shape: the property is
// there, registration never happens, and nothing ever controls the page. Modelled separately
// from `swControlled` because a stand-in that conflated them is what let the bug ship.
navigator: Object.assign(
{ userAgent: 'BrightSign/9.0.189 (XT245) Chrome/120' },
opts.swControlled ? { serviceWorker: { controller: {} } } : (opts.sw ? { serviceWorker: {} } : {})
),
location: { search: '' }, location: { search: '' },
setInterval: () => 1, setInterval: () => 1,
setTimeout: (fn) => { inbound.push(fn); return 1; }, // deterministic: fired manually setTimeout: (fn) => { inbound.push(fn); return 1; }, // deterministic: fired manually
@ -171,9 +178,22 @@ test('NEVER declared: the things BrightSign genuinely has no equivalent for', ()
} }
}); });
test('offline.cache follows the service worker, not the platform', () => { test('offline.cache needs a worker in CONTROL, not merely a navigator property', () => {
assert.ok(load({ probeRes: WITH_DISK }).caps.includes('offline.cache')); // Found on hardware, and this test used to assert the bug. `navigator.serviceWorker` EXISTS on a
// BrightSign widget and is not usable: our XT245 on alpha passes the presence check, then never
// even fetches sw.js. Presence was therefore the one signal that could not tell "caches offline"
// from "cannot", and it answered yes to both — so every BrightSign in the fleet advertised an
// offline capability it could not honour, which is precisely the dead-button failure this whole
// capability model exists to remove.
//
// A controller is proof rather than a promise: something is actually intercepting this page's
// fetches. The web player learned this a release ago (declareCapabilities in
// server/player/index.html); this copy had not.
assert.ok(!load({ probeRes: WITH_DISK, sw: true }).caps.includes('offline.cache'),
'a runtime that exposes serviceWorker and will not run one must not claim to cache');
assert.ok(!load({ probeRes: WITH_DISK, sw: false }).caps.includes('offline.cache')); assert.ok(!load({ probeRes: WITH_DISK, sw: false }).caps.includes('offline.cache'));
assert.ok(load({ probeRes: WITH_DISK, swControlled: true }).caps.includes('offline.cache'),
'a worker that IS controlling the page is a real offline story and must still be declared');
}); });
test('the probe is actually sent to the host', () => { test('the probe is actually sent to the host', () => {

View file

@ -24,6 +24,46 @@ const bridge = fs.readFileSync(path.join(ROOT, 'brightsign', 'st-bridge.js'), 'u
const player = fs.readFileSync(path.join(ROOT, 'server', 'player', 'index.html'), 'utf8'); const player = fs.readFileSync(path.join(ROOT, 'server', 'player', 'index.html'), 'utf8');
const code = host.split('\n').filter((l) => !/^\s*'/.test(l)).join('\n'); const code = host.split('\n').filter((l) => !/^\s*'/.test(l)).join('\n');
// Objects built inside the vm carry the vm realm's prototypes, so deepStrictEqual would compare
// realms rather than values. Round-trip through JSON to compare what actually crossed the bridge.
const norm = (x) => JSON.parse(JSON.stringify(x));
/*
* The bridge half, actually EXECUTED against a fake widget rather than pattern-matched.
*
* The two source-regex assertions this replaces both passed while the chain was broken end to end,
* which is the whole argument for running it: "the file contains onHostLog" is not evidence that a
* log line reaches anybody. `deliver` plays the part of roHtmlWidget.PostJSMessage.
*/
function loadBridge() {
const vm = require('node:vm');
const inbound = [];
const sandbox = {
console: { log() {}, warn() {}, error() {} },
navigator: { userAgent: 'BrightSign/9.0.189 (XT245) Chrome/120' },
location: { search: '' },
setTimeout: () => 1, setInterval: () => 1, clearTimeout: () => {},
Promise, Object, Array, Uint8Array, Math, Date, RegExp, String, Number,
parseInt, isNaN, isFinite, decodeURIComponent, Error,
localStorage: { getItem: () => null, setItem() {} },
};
sandbox.window = sandbox;
sandbox.require = (name) => {
if (name === '@brightsign/messageport') {
return function () {
return {
PostBSMessage: () => {},
addEventListener: (evt, fn) => { if (evt === 'bsmessage') inbound.push(fn); },
};
};
}
throw new Error('no module ' + name);
};
vm.createContext(sandbox);
vm.runInContext(bridge, sandbox);
return { api: sandbox.ScreenTinkerBS, deliver: (msg) => inbound.forEach((fn) => fn(msg)) };
}
test('the host reports its boot story, which happens before there is a page to hear it', () => { test('the host reports its boot story, which happens before there is a page to hear it', () => {
// The interesting failures all live in this window: the storage probe, a pending package being // The interesting failures all live in this window: the storage probe, a pending package being
// applied, the video mode being set. A design that could only report after the widget existed // applied, the video mode being set. A design that could only report after the widget existed
@ -31,7 +71,7 @@ test('the host reports its boot story, which happens before there is a page to h
assert.match(code, /Sub LogTo\(buf As Object/, 'a buffer the pre-widget phase can log into'); assert.match(code, /Sub LogTo\(buf As Object/, 'a buffer the pre-widget phase can log into');
assert.match(code, /Sub FlushLog\(widget As Object, buf As Object\)/, 'and a flush once there is a page'); assert.match(code, /Sub FlushLog\(widget As Object, buf As Object\)/, 'and a flush once there is a page');
assert.match(code, /boot = CreateObject\("roArray"/, 'Main must create the buffer'); assert.match(code, /boot = CreateObject\("roArray"/, 'Main must create the buffer');
assert.match(code, /FlushLog\(widget, boot\)/, 'and flush it once the widget exists'); assert.match(code, /FlushLog\(widget, boot\)/, 'and flush it once a page is listening');
// The update path is the one that replaces the boot script — its diagnostics are the ones you // The update path is the one that replaces the boot script — its diagnostics are the ones you
// most want when a player does not come back. // most want when a player does not come back.
assert.match(code, /Sub ApplyPendingPackage\(root As String, buf As Object\)/); assert.match(code, /Sub ApplyPendingPackage\(root As String, buf As Object\)/);
@ -70,12 +110,74 @@ test('the event types the host emits are ones the server actually accepts', () =
}); });
test('the bridge carries logs and events without interpreting them', () => { test('the bridge carries logs and events without interpreting them', () => {
assert.match(bridge, /onHostLog: function/); const { api, deliver } = loadBridge();
assert.match(bridge, /onHostEvent: function/); const logs = [];
const events = [];
api.onHostLog((l) => logs.push(l));
api.onHostEvent((e) => events.push(e));
deliver({ type: 'host-log', tag: 'update', level: 'i', message: 'package applied' });
deliver({ type: 'host-event', event: 'crash', reason: 'watchdog', detail: 'no heartbeat for 120s' });
assert.deepEqual(norm(logs), [{ tag: 'update', level: 'i', message: 'package applied' }]);
assert.deepEqual(norm(events), [{ event: 'crash', reason: 'watchdog', detail: 'no heartbeat for 120s' }]);
// Bounded before they reach the wire: the server truncates too, but a host bug should not be // Bounded before they reach the wire: the server truncates too, but a host bug should not be
// able to push a megabyte through the socket every second. // able to push a megabyte through the socket every second.
assert.match(bridge, /msg\.message \|\| ''\)\.slice\(0, 2000\)/); deliver({ type: 'host-log', tag: 'x'.repeat(200), message: 'y'.repeat(5000) });
assert.match(bridge, /msg\.detail \|\| ''\)\.slice\(0, 500\)/); deliver({ type: 'host-event', event: 'app_error', reason: 'r'.repeat(200), detail: 'd'.repeat(5000) });
assert.equal(logs[1].message.length, 2000);
assert.equal(logs[1].tag.length, 64);
assert.equal(events[1].detail.length, 500);
assert.equal(events[1].reason.length, 64);
});
test('THE DROPPED BOOT REPORT: a diagnostic sent before anyone subscribed is still delivered', () => {
// The regression this whole file exists to prevent, and it was live. The ordering is not an edge
// case, it is the ONLY ordering: the host buffers its pre-widget lines and posts them the instant
// the page says hello, while the player deliberately does not subscribe until its socket is up
// (forwarding earlier would have nowhere to send them). Between those two correct decisions every
// boot line fell on the floor — the host spoke to a page with no listener, and the listener
// arrived after the words had gone.
//
// So the bridge holds them. Nothing else in the chain can: the host has already moved on and the
// player cannot subscribe any earlier.
const { api, deliver } = loadBridge();
deliver({ type: 'host-log', tag: 'boot', level: 'i', message: 'host 1.2.3 from SSD: -> https://s' });
deliver({ type: 'host-log', tag: 'update', level: 'i', message: 'package applied — rebooting into it' });
deliver({ type: 'host-event', event: 'app_error', reason: 'load-error', detail: 'attempt 1: https://s/player' });
const logs = [];
const events = [];
api.onHostLog((l) => logs.push(l));
api.onHostEvent((e) => events.push(e));
assert.deepEqual(logs.map((l) => l.tag), ['boot', 'update'], 'the boot story must survive the gap');
assert.deepEqual(events.map((e) => e.event), ['app_error']);
// ...and delivery keeps working normally afterwards, oldest-first with no duplication.
deliver({ type: 'host-log', tag: 'tel', level: 'i', message: 'later' });
assert.deepEqual(logs.map((l) => l.tag), ['boot', 'update', 'tel']);
});
test('a second subscriber gets the same history, and one that throws cannot eat it', () => {
const { api, deliver } = loadBridge();
deliver({ type: 'host-log', tag: 'boot', level: 'i', message: 'early' });
api.onHostLog(() => { throw new Error('a consumer blew up'); });
const logs = [];
api.onHostLog((l) => logs.push(l));
assert.deepEqual(logs.map((l) => l.message), ['early'], 'a broken consumer must not swallow the replay');
});
test('the pending queue is bounded — a reboot loop must not grow it without limit', () => {
// This player runs for months. An unbounded buffer fed by a host stuck in a loop is a slow leak
// on the one device nobody is watching.
const { api, deliver } = loadBridge();
for (let i = 0; i < 5000; i++) deliver({ type: 'host-log', tag: 'boot', message: 'line ' + i });
const logs = [];
api.onHostLog((l) => logs.push(l));
assert.ok(logs.length > 0 && logs.length <= 200, `queue must be capped, got ${logs.length}`);
}); });
test('host telemetry merges into the snapshot the heartbeat already sends', () => { test('host telemetry merges into the snapshot the heartbeat already sends', () => {
@ -87,12 +189,30 @@ test('host telemetry merges into the snapshot the heartbeat already sends', () =
}); });
test('the host telemetry listener is registered at load, not behind the readiness gate', () => { test('the host telemetry listener is registered at load, not behind the readiness gate', () => {
// The host sends its boot report the moment the widget exists. A listener attached after the // The host sends its boot report the moment the page says hello. A listener attached after the
// bridge finished its own probe would miss precisely the message that says which volume the // bridge finished its own probe would miss precisely the message that says which volume the
// player came up from and whether a package applied. // player came up from and whether a package applied.
const seg = bridge.slice(bridge.indexOf('var telemetry = {}'), bridge.indexOf('var telemetry = {}') + 1400); //
assert.match(seg, /listeners\.push\(function \(msg\)/); // Executed, not pattern-matched: the previous form asserted that a `listeners.push` appeared
assert.ok(!/onReady\(function/.test(seg), 'must not wait on readiness to start listening'); // within 1400 characters of a variable declaration, which is a statement about formatting.
const { api, deliver } = loadBridge();
deliver({ type: 'host-telemetry', boot_volume: 'SSD:', storage_free_mb: 90000, package_version: '1.2.3' });
assert.deepEqual(norm(api.telemetrySnapshot()), {
boot_volume: 'SSD:', storage_free_mb: 90000, package_version: '1.2.3',
});
});
test('the host holds its boot log until a PAGE answers, not until a widget exists', () => {
// Show() only creates the widget: the page has not been fetched, let alone run st-bridge.js, so a
// flush there posts into a void. The `probe` message is the first proof that JavaScript is running
// on the other end, and is therefore the earliest moment the buffer can actually be delivered.
const main = code.slice(code.indexOf('Sub Main()'));
const afterShow = main.slice(main.indexOf('widget.Show()'), main.indexOf('widget.Show()') + 400);
assert.ok(!/FlushLog\(/.test(afterShow),
'flushing straight after Show() posts the boot story to a page that has not loaded yet');
const probeBranch = main.slice(main.indexOf('m.type = "probe"'), main.indexOf('m.type = "probe"') + 400);
assert.match(probeBranch, /FlushLog\(widget, boot\)/, 'flush when the page proves it is listening');
}); });
test('the player forwards them, and only where the hooks exist', () => { test('the player forwards them, and only where the hooks exist', () => {

View file

@ -221,3 +221,269 @@ test('an item with no revision still caches, and matches a copy stored without o
await mc.sync([{ content_id: 'c1' }], () => 'http://s/x'); await mc.sync([{ content_id: 'c1' }], () => 'http://s/x');
assert.equal(b.requests, after); assert.equal(b.requests, after);
}); });
/* ================================================================================================
* THE ADAPTER ITSELF.
*
* Everything above drives the DECISION layer against a fake backend, which is the right way to test
* decisions and it is also how the real adapter shipped unable to write a single byte without one
* assertion noticing. The fake was correct; the platform calls underneath it were not:
*
* - `var dir = tizen.filesystem.resolve(...)` resolve() is declared `void`. dir was undefined,
* available() answered false, and MediaCache.create() returned null on every panel in the
* fleet. The offline cache did not exist. It failed CLOSED, which is the only reason this never
* showed up as corruption: the capability was correctly withheld and the feature was absent.
* - `f.openStream('a', cb)` asynchronous. `written` was read on the next line, before any
* callback could have run, so appendPart returned 0 forever.
* - `part.moveTo(destPath, name, ...)` asynchronous, belongs on the parent DIRECTORY, and takes
* (originFullPath, destinationFullPath). Called on a file handle with the arguments transposed:
* three documented errors in one call, each of which alone raises IOError.
*
* So the adapter is exercised here too, against a fake `tizen.filesystem` written from Samsung's
* published IDL rather than from our code including the deprecated calls, modelled with their
* documented (useless-to-us) semantics, so reaching for them again fails here instead of in a shop.
* ============================================================================================== */
/** A fake Samsung TV, per developer.samsung.com/smarttv Filesystem API. */
function fakeTizen({ version = 5.0 } = {}) {
const files = new Map(); // full virtual path -> number[]
const calls = [];
function makeFile(p) {
return {
fullPath: p,
isDirectory: false,
get fileSize() { return (files.get(p) || []).length; },
toURI: () => 'file:///opt/usr/apps/priv/' + p,
// "This operation is performed asynchronously."
openStream(mode, onsuccess) {
calls.push('openStream');
const stream = {
writeBytes(bytes) {
const cur = files.get(p) || [];
files.set(p, mode === 'a' ? cur.concat(Array.from(bytes)) : Array.from(bytes));
},
close() {},
};
if (onsuccess) setTimeout(() => onsuccess(stream), 0);
},
// "IOError - If the File in which the moveTo() method is invoked is a file (not a directory)"
moveTo() { const e = new Error('IOError'); e.name = 'IOError'; throw e; },
};
}
// --- deprecated 1.0 surface, with its REAL semantics ---------------------------------------
const deprecated = {
// "void resolve(...)" — the File arrives only via the callback; the return value is undefined.
resolve(location, onsuccess) {
calls.push('resolve');
const dir = {
isDirectory: true,
fullPath: location,
resolve(name) {
const p = location + '/' + name;
if (!files.has(p)) { const e = new Error('NotFoundError'); e.name = 'NotFoundError'; throw e; }
return makeFile(p);
},
createFile(name) { const p = location + '/' + name; files.set(p, []); return makeFile(p); },
deleteFile(p, ok) { files.delete(p); if (ok) setTimeout(ok, 0); },
moveTo(origin, dest, overwrite, ok) {
if (!files.has(origin)) { const e = new Error('NotFoundError'); e.name = 'NotFoundError'; throw e; }
files.set(dest, files.get(origin)); files.delete(origin);
if (ok) setTimeout(ok, 0);
},
};
if (onsuccess) setTimeout(() => onsuccess(dir), 0);
return undefined;
},
};
if (version < 5) return { filesystem: deprecated, __files: files, __calls: calls };
// --- 5.0 synchronous FileSystemManager -----------------------------------------------------
const modern = Object.assign({}, deprecated, {
pathExists(p) { return files.has(p); },
toURI(p) { return 'file:///opt/usr/apps/priv/' + p; },
deleteFile(p, ok) { files.delete(p); if (ok) setTimeout(ok, 0); },
// "FileHandle openFile(Path path, FileMode openMode, optional boolean makeParents)" — RETURNS.
openFile(p, mode, makeParents) {
calls.push('openFile:' + mode);
if (!files.has(p)) {
if (mode === 'r') { const e = new Error('NotFoundError'); e.name = 'NotFoundError'; throw e; }
files.set(p, []);
}
if (mode === 'w') files.set(p, []); // 'w' truncates
let pos = 0;
return {
path: p,
seek(offset) { pos = offset; return this; },
writeData(u8) {
const cur = files.get(p).slice();
// A positioned write: pad any gap, then overwrite in place — what seek+write really does.
while (cur.length < pos) cur.push(0);
for (let i = 0; i < u8.length; i++) cur[pos + i] = u8[i];
pos += u8.length;
files.set(p, cur);
},
flush() {}, close() {},
};
},
});
return { filesystem: modern, __files: files, __calls: calls };
}
function withTizen(fake, fn) {
const hadT = 'tizen' in global; const oldT = global.tizen;
const hadL = 'localStorage' in global; const oldL = global.localStorage;
const store = new Map();
global.tizen = fake;
global.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, v) };
try { return fn(); } finally {
if (hadT) global.tizen = oldT; else delete global.tizen;
if (hadL) global.localStorage = oldL; else delete global.localStorage;
}
}
const MEDIA_DIR = 'wgt-private/st-media/';
test('ADAPTER: a Tizen 5.0 panel really can write, resume and hand back a URI', () => {
const fake = fakeTizen({ version: 5.0 });
withTizen(fake, () => {
const b = MediaCache.tizenBackend();
assert.equal(b.available(), true, 'the 5.0 synchronous filesystem must be usable');
// The contract the decision layer depends on: appendPart returns the number of bytes WRITTEN,
// synchronously. Returning 0 here is what made every download stall forever.
assert.equal(b.appendPart('c1', [1, 2, 3], 0), 3, 'a first write must report what it wrote');
assert.equal(b.appendPart('c1', [4, 5], 3), 2, 'and so must a resumed one');
assert.deepEqual(fake.__files.get(MEDIA_DIR + 'c1'), [1, 2, 3, 4, 5]);
const p = b.promotePart('c1');
assert.ok(p && p.uri.startsWith('file://'), 'a finished asset must resolve to a playable URI');
b.remove('c1');
assert.equal(fake.__files.has(MEDIA_DIR + 'c1'), false);
assert.equal(b.promotePart('c1'), null, 'and must not claim a file that is gone');
});
});
test('ADAPTER: writes are POSITIONED, so replaying a chunk cannot corrupt the file', () => {
// The crash window is real and is exactly the event this feature exists for: power is cut between
// the write and the index save, so the next boot replays the last chunk. An append lands it a
// second time and the panel promotes a silently corrupt video that plays as garbage. A positioned
// write overwrites the same bytes with the same bytes, and the window stops mattering.
const fake = fakeTizen({ version: 5.0 });
withTizen(fake, () => {
const b = MediaCache.tizenBackend();
b.appendPart('c1', [1, 2, 3, 4], 0);
b.appendPart('c1', [5, 6], 4);
b.appendPart('c1', [5, 6], 4); // the replay
assert.deepEqual(fake.__files.get(MEDIA_DIR + 'c1'), [1, 2, 3, 4, 5, 6],
'a replayed chunk must overwrite, not append');
});
});
test('ADAPTER: offset 0 truncates, so a lost index restarts cleanly instead of prepending', () => {
const fake = fakeTizen({ version: 5.0 });
withTizen(fake, () => {
const b = MediaCache.tizenBackend();
b.appendPart('c1', [9, 9, 9, 9, 9, 9], 0);
b.appendPart('c1', [1, 2], 0);
assert.deepEqual(fake.__files.get(MEDIA_DIR + 'c1'), [1, 2]);
});
});
test('ADAPTER: a panel without the synchronous filesystem says so instead of writing nothing', () => {
// Tizen 4.0 (2018 models). The deprecated resolve()/openStream() pair cannot serve a synchronous
// backend at all, and a cache that reports itself available and then silently writes zero bytes is
// worse than no cache — capabilities.js declares offline.cache on the strength of create().
for (const fake of [fakeTizen({ version: 4.0 }), { filesystem: null }, {}]) {
withTizen(fake, () => {
const b = MediaCache.tizenBackend();
assert.equal(b.available(), false);
assert.equal(b.appendPart('c1', [1, 2, 3], 0), 0, 'and must not pretend to have written');
assert.equal(b.promotePart('c1'), null);
assert.equal(MediaCache.create(), null, 'so no cache is created, and no capability claimed');
});
}
});
test('ADAPTER: no deprecated asynchronous call is on the write path', () => {
// Belt and braces against the exact regression: resolve(), openStream() and moveTo() all hand
// their result to a callback, so any backend built on them returns before it has done anything.
const fake = fakeTizen({ version: 5.0 });
withTizen(fake, () => {
const b = MediaCache.tizenBackend();
b.appendPart('c1', [1], 0);
b.promotePart('c1');
b.remove('c1');
assert.deepEqual(fake.__calls.filter((c) => c === 'resolve' || c === 'openStream'), [],
'resolve() returns void and openStream() is async — neither can serve a synchronous backend');
});
});
test('a 206 with no readable Content-Range is a stall, never a completed asset', async () => {
// Content-Length on a 206 is the length of the CHUNK. Trusting it reports the first 1MB of a 50MB
// video as a 1MB asset — complete, promoted, and truncated on screen. A proxy that strips
// Content-Range, or a CORS context where the header simply is not readable, produces exactly it.
const b = fakeBackend(asset(CHUNK * 4, 2));
const mc = new MediaCache(b);
// The entry fetchStep would have created before the first request.
mc.index.c1 = { rev: 5, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null };
const verdict = await mc.applyChunk('c1', 5, {
status: 206, start: 0, total: 0, validator: '"v1"', body: new Array(CHUNK).fill(2),
});
assert.equal(verdict, 'stalled');
assert.equal(mc.localUrl('c1', 5), null, 'a truncated file must never be handed to the player');
});
test('a 200 whose body is short of its own length is progress, not done', async () => {
const b = fakeBackend(asset(CHUNK, 1));
const mc = new MediaCache(b);
mc.index.c1 = { rev: 5, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null };
const verdict = await mc.applyChunk('c1', 5, {
status: 200, start: 0, total: CHUNK * 3, validator: '"v1"', body: new Array(CHUNK).fill(1),
});
assert.equal(verdict, 'progress', "'done' would stop the sweep on an asset with more to fetch");
assert.equal(mc.localUrl('c1', 5), null);
});
test('a server with no validator is given up on, not re-fetched forever', async () => {
// A big asset from a server that sends neither ETag nor Last-Modified can never be resumed, so the
// partial is correctly discarded. Dropping alone meant the next sweep started from zero, pulled
// the same megabyte and discarded it again — every sweep, forever, on precisely the marginal link
// this whole feature exists to be gentle on.
const b = fakeBackend(asset(CHUNK * 4, 3));
b.asset.etag = null;
const mc = new MediaCache(b);
await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor);
const afterFirst = b.requests;
assert.ok(afterFirst > 0, 'it must at least try once');
await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor);
await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor);
assert.equal(b.requests, afterFirst, 'and then stop asking');
assert.equal(mc.localUrl('c1', 5), null, 'while never claiming to hold it');
});
test('...and a new revision clears that verdict rather than blacklisting the asset forever', async () => {
const b = fakeBackend(asset(CHUNK * 4, 3));
b.asset.etag = null;
const mc = new MediaCache(b);
await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor);
const afterFirst = b.requests;
b.asset.etag = '"v2"'; // the server grew validators
await mc.sync([{ content_id: 'c1', content_rev: 6 }], urlFor);
assert.ok(b.requests > afterFirst, 'a fresh revision must be tried again');
assert.equal(mc.localUrl('c1', 6), 'file:///wgt-private/c1');
});
test('a small asset from a validator-less server still caches — it never needs a resume', async () => {
const b = fakeBackend(asset(Math.floor(CHUNK / 2), 4));
b.asset.etag = null;
const mc = new MediaCache(b);
await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor);
assert.equal(mc.localUrl('c1', 5), 'file:///wgt-private/c1');
});

View file

@ -0,0 +1,140 @@
'use strict';
/*
* The volume slider, end to end.
*
* The dashboard sends `set_volume` with `{ level: <0..1 fraction> }`
* (frontend/js/views/device-detail.js: `{ level: parseInt(el.value, 10) / 100 }`). The Android
* player reads exactly that (`payload.optDouble("level")`). The Tizen player read `value`/`volume`
* as a 0..100 PERCENTAGE, so it matched nothing the dashboard has ever sent: every slider move
* logged "no usable value in payload" and changed nothing, on a panel that declared audio.volume as
* a working capability.
*
* Two mistakes, and fixing either one alone is worse than fixing neither:
* - the KEY: `level`, not `value`/`volume`
* - the SCALE: a fraction, not a percentage
* Take `level` while still treating it as a percentage and a request for 50% becomes 0.5% silent,
* and indistinguishable from a slider that works.
*
* The handler is EXECUTED here, lifted out of the shipped app.js the same way the wall-geometry
* parity test lifts the Tizen tile maths. A regex asserting that the file mentions "level" would
* pass on the 0.5%-instead-of-50% version, which is the one failure mode that matters.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const APP = fs.readFileSync(path.join(ROOT, 'tizen', 'js', 'app.js'), 'utf8');
/**
* Lift the real applyVolume out of app.js and run it against recording stubs.
* Returns { set(payload) -> {tv, media, warned} }.
*/
function loadHandler({ hasTvAudio = true } = {}) {
const m = /(\n\s*var mediaVolume = null;[\s\S]*?\n function applyVolume\(payload\) \{[\s\S]*?\n \})/.exec(APP);
assert.ok(m, 'could not find applyVolume in tizen/js/app.js');
const calls = { tv: null, media: null, logs: [] };
// A vm context rather than `new Function`, because the handler reaches STCapabilities as a BARE
// global (`window.STCapabilities ? STCapabilities.tvAudio() : null`) — a local `var window` would
// leave that a ReferenceError, the try/catch would swallow it, and the test would silently
// exercise only the fallback path while claiming to cover the TV one.
const vm = require('node:vm');
const sandbox = {
STCapabilities: {
tvAudio: () => (hasTvAudio ? { setVolume: (v) => { calls.tv = v; } } : null),
},
reportCmd: (level, cmd, msg) => calls.logs.push(level + ':' + msg),
Number, Math, isFinite,
__calls: calls,
};
sandbox.window = sandbox;
vm.createContext(sandbox);
vm.runInContext(`
function applyMediaVolume() { __calls.media = mediaVolume; }
${m[1]}
`, sandbox);
const harness = sandbox.applyVolume;
assert.equal(typeof harness, 'function');
return {
set(payload) {
calls.tv = null; calls.media = null; calls.logs = [];
harness(payload);
return {
tv: calls.tv,
media: calls.media,
warned: calls.logs.some((l) => l.startsWith('warn')),
logs: calls.logs,
};
},
};
}
test('THE DASHBOARD PAYLOAD: {level: 0..1} reaches the TV as 0..100', () => {
const h = loadHandler();
// Exactly what frontend/js/views/device-detail.js sends for slider positions 0 / 50 / 100.
assert.deepEqual(h.set({ level: 0 }).tv, 0);
assert.deepEqual(h.set({ level: 0.5 }).tv, 50, 'half volume must be 50, not 0.5');
assert.deepEqual(h.set({ level: 1 }).tv, 100, 'full volume must be 100, not 1');
assert.equal(h.set({ level: 0.5 }).warned, false, 'and must not report the payload unusable');
});
test('THE TRAP: reading `level` as a percentage would be inaudible, not merely wrong', () => {
// 0.5 interpreted as a percentage is 0.5% — near silence. It changes the volume, logs success,
// and looks from the dashboard exactly like a working slider. This is the assertion that
// distinguishes a real fix from a plausible one.
const h = loadHandler();
assert.ok(h.set({ level: 0.5 }).tv > 1, 'a fraction must be scaled, not clamped into near-silence');
});
test('tvaudiocontrol is preferred — it is the volume that reaches the panel speakers', () => {
// Tizen has two volumes and only one of them is audible on a TV: tizen.tvaudiocontrol is the
// SET's own volume and applies to AVPlay video on the hardware plane, which the media elements
// cannot touch at all. Portrait video (#170) plays through AVPlay, so a media-element-only
// implementation would leave a rotated panel at full blast.
const withTv = loadHandler({ hasTvAudio: true }).set({ level: 0.3 });
assert.equal(withTv.tv, 30);
assert.equal(withTv.media, null, 'the media fallback must not also run');
});
test('...and a build with no TV profile still moves the media elements', () => {
// The URL-Launcher path and a plain browser have no tv.audio surface. Falling through keeps the
// control honest rather than silently doing nothing.
const noTv = loadHandler({ hasTvAudio: false }).set({ level: 0.4 });
assert.equal(noTv.tv, null);
assert.ok(Math.abs(noTv.media - 0.4) < 1e-9, 'media elements take a 0..1 fraction');
});
test('legacy percentage senders still work, so one dead control is not traded for another', () => {
// The group-command route and hand-issued commands use `value`. These are percentages, not
// fractions — a different key, so there is no ambiguity to resolve.
const h = loadHandler();
assert.equal(h.set({ value: 25 }).tv, 25);
assert.equal(h.set({ volume: 70 }).tv, 70);
});
test('a payload with nothing usable is refused loudly rather than defaulting to silence', () => {
const h = loadHandler();
const r = h.set({ nothing: true });
assert.equal(r.tv, null);
assert.ok(r.warned, 'an unusable payload must say so — a silent 0 reads as broken hardware');
});
test('out-of-range values are clamped, not passed through to the panel API', () => {
const h = loadHandler();
assert.equal(h.set({ level: 5 }).tv, 100);
assert.equal(h.set({ level: -2 }).tv, 0);
});
test('the dashboard really does send `level` as a fraction — the other half of the contract', () => {
// Pinned against the sender, because this test is only meaningful while that stays true. If the
// dashboard ever switches to percentages, this fails here instead of on a shop floor.
const ui = fs.readFileSync(path.join(ROOT, 'frontend', 'js', 'views', 'device-detail.js'), 'utf8');
assert.match(ui, /sendCommand\(device\.id, cmd, \{ level: parseInt\(el\.value, 10\) \/ 100 \}\)/,
'the set_volume wire format is { level: 0..1 }');
});

View file

@ -530,8 +530,29 @@
// context), and remembers the level so items mounted LATER inherit it — media elements are // context), and remembers the level so items mounted LATER inherit it — media elements are
// created per item, so a one-shot set would last only until the playlist advanced. // created per item, so a one-shot set would last only until the playlist advanced.
var mediaVolume = null; // 0..1, null = never set var mediaVolume = null; // 0..1, null = never set
/*
* THE WIRE FORMAT IS `level`, AND IT IS A 0..1 FRACTION.
*
* That is what the dashboard sends `sendCommand(id, 'set_volume', { level: value / 100 })` in
* frontend/js/views/device-detail.js and what the Android player reads (`optDouble("level")`).
* This handler looked for `value`/`volume` as a 0..100 percentage, so it matched nothing the
* dashboard has ever sent: every slider move reported "no usable value in payload" and changed
* nothing, while the panel declared audio.volume as a working capability.
*
* Both halves had to move together, and that is the trap. Accepting `level` while still treating
* it as a percentage turns a request for 50% into 0.5% inaudible, indistinguishable from broken,
* and it would have looked exactly like a fix.
*/
function applyVolume(payload) { function applyVolume(payload) {
var pct = payload && (payload.value !== undefined ? payload.value : payload.volume); var pct;
if (payload && payload.level !== undefined && isFinite(Number(payload.level))) {
pct = Number(payload.level) * 100; // the canonical wire form: a fraction
} else if (payload && payload.value !== undefined) {
pct = payload.value; // legacy/hand-issued: already a percentage
} else if (payload) {
pct = payload.volume;
}
var n = Number(pct); var n = Number(pct);
if (!isFinite(n)) { reportCmd('warn', 'set_volume', 'no usable value in payload'); return; } if (!isFinite(n)) { reportCmd('warn', 'set_volume', 'no usable value in payload'); return; }
n = Math.max(0, Math.min(100, n)); n = Math.max(0, Math.min(100, n));
@ -811,20 +832,22 @@
// that survives an outage and media that does not just means the panel knows precisely what it // that survives an outage and media that does not just means the panel knows precisely what it
// cannot show. Deferred off the render path: the sweep is synchronous and this call arrives // cannot show. Deferred off the render path: the sweep is synchronous and this call arrives
// while the stage is being repainted. // while the stage is being repainted.
try {{ // (The doubled braces this block used to carry were a templating artifact, not syntax: `{{ }}`
// parses as a block inside a block, so it ran correctly and read as a typo in eight places.)
try {
if (!window.__stMediaCache && window.MediaCache) window.__stMediaCache = window.MediaCache.create(); if (!window.__stMediaCache && window.MediaCache) window.__stMediaCache = window.MediaCache.create();
var mc = window.__stMediaCache; var mc = window.__stMediaCache;
if (mc && serverUrl) {{ if (mc && serverUrl) {
var mcItems = payload.assignments || []; var mcItems = payload.assignments || [];
var mcBase = serverUrl.replace(/\/+$/, ''); var mcBase = serverUrl.replace(/\/+$/, '');
setTimeout(function () {{ setTimeout(function () {
mc.sync(mcItems, function (it) {{ mc.sync(mcItems, function (it) {
return mcBase + '/api/content/' + it.content_id + '/file' return mcBase + '/api/content/' + it.content_id + '/file'
+ (it.content_rev ? '?rev=' + encodeURIComponent(it.content_rev) : ''); + (it.content_rev ? '?rev=' + encodeURIComponent(it.content_rev) : '');
}}); });
}}, 2000); }, 2000);
}} }
}} catch (e) {{ /* caching must never break the payload path */ }} } catch (e) { /* caching must never break the payload path */ }
// If we have content + we're paired, make sure we're on the stage. // If we have content + we're paired, make sure we're on the stage.
if (elPairing.classList.contains('hidden') === false) show(elStage); if (elPairing.classList.contains('hidden') === false) show(elStage);
else if (elStage.classList.contains('hidden')) show(elStage); else if (elStage.classList.contains('hidden')) show(elStage);

View file

@ -81,6 +81,9 @@
e = null; e = null;
} }
if (e && e.complete) return Promise.resolve('done'); if (e && e.complete) return Promise.resolve('done');
// A server that offered no validator for this revision cannot be resumed from, and asking again
// only re-downloads a chunk we already know we will throw away. See applyChunk.
if (e && e.unresumable) return Promise.resolve('stalled');
if (!e) { if (!e) {
e = this.index[contentId] = { rev: rev, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null }; e = this.index[contentId] = { rev: rev, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null };
@ -120,7 +123,11 @@
// WHOLE asset and anything we already hold is wrong. // WHOLE asset and anything we already hold is wrong.
this.drop(contentId); this.drop(contentId);
e = this.index[contentId] = { rev: rev, bytes: 0, total: res.total || 0, validator: res.validator || null, complete: false, path: null, uri: null }; e = this.index[contentId] = { rev: rev, bytes: 0, total: res.total || 0, validator: res.validator || null, complete: false, path: null, uri: null };
return this.commit(contentId, e, res.body, 0, res.total || (res.body && res.body.length) || 0) ? 'done' : 'stalled'; if (!this.commit(contentId, e, res.body, 0, res.total || (res.body && res.body.length) || 0)) return 'stalled';
// 'done' is a claim about the ASSET, not about the write. A 200 whose body is shorter than
// its own Content-Length (a truncated proxy response) wrote successfully and is still
// incomplete; reporting 'done' there stopped the sweep on an asset that had more to fetch.
return e.complete ? 'done' : 'progress';
} }
if (res.status !== 206) return 'stalled'; if (res.status !== 206) return 'stalled';
@ -138,7 +145,25 @@
var wrote = this.commit(contentId, e, res.body, e.bytes, res.total); var wrote = this.commit(contentId, e, res.body, e.bytes, res.total);
if (!wrote) return 'stalled'; if (!wrote) return 'stalled';
if (!e.validator && !e.complete) { this.drop(contentId); return 'stalled'; }
/*
* No validator and more to fetch: there is no safe resume. A later attempt could append the
* tail of a different asset, so the bytes have to go.
*
* What matters is that we then STOP asking. Dropping alone left the entry absent, so the next
* sweep started from zero, pulled the same first megabyte, dropped it again, and did that every
* sweep forever burning the link this feature exists to be gentle on, permanently, for an
* asset it could never finish. A tombstone at the current revision records "we tried, this
* server will not let us resume" and costs one skipped item instead. Publishing a new revision
* clears it (prune drops anything at a superseded rev), and an asset small enough to arrive in
* one chunk is unaffected it completes before this branch is reached.
*/
if (!e.validator && !e.complete) {
this.drop(contentId);
this.index[contentId] = { rev: rev, unresumable: true, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null };
this.save();
return 'stalled';
}
return e.complete ? 'done' : 'progress'; return e.complete ? 'done' : 'progress';
}; };
@ -237,25 +262,51 @@
/* ------------------------------------------------------------------ * /* ------------------------------------------------------------------ *
* The Tizen adapter. No decisions live here only platform calls. * The Tizen adapter. No decisions live here only platform calls.
*
* EVERY CALL HERE IS THE 5.0 SYNCHRONOUS FileSystemManager, deliberately, and the version
* before it used the DEPRECATED callback API in a way that could not work at all:
*
* tizen.filesystem.resolve(...) is declared `void`. It hands the directory to a callback and
* returns undefined so `var dir = tizen.filesystem.resolve(...)` set dir to undefined,
* available() answered false, MediaCache.create() returned null, and the offline cache did
* not exist on a single panel in the field. It failed CLOSED, which is the only reason this
* never showed up as corruption: the capability was correctly withheld, and the feature was
* simply never there.
* File.openStream(...) is asynchronous. `written` was read on the line after the
* call, before any callback could have run, so appendPart returned 0 every time.
* File.moveTo(...) is asynchronous, belongs on the PARENT DIRECTORY, and takes
* (originFullPath, destinationFullPath). It was called on the FILE handle with the
* destination first and a bare name second three documented errors in one call, each of
* which alone raises IOError.
*
* The 5.0 API (`openFile` -> FileHandle, `toURI`, `pathExists`) is genuinely synchronous, which
* is what the decision layer above actually needs. Tizen 5.0 is the 2019 model year; a 4.0 panel
* has none of it and is told so by available() rather than being handed a cache that writes
* nothing.
*
* There is no rename step. `moveFile` is callback-based even in the 5.0 API, and promoting a
* finished download by renaming it was only ever belt-and-braces: `localUrl` already refuses to
* hand out an entry that is not `complete`, so a partial file at the final name is unreachable.
* Removing the rename removes the last asynchronous operation from this adapter.
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
function tizenBackend() { var DIR = 'wgt-private/st-media';
var dir = null;
try {
// Synchronous resolve is deprecated in newer Web APIs but is what the widget runtime on the
// shipped panels supports; the async form would force this whole module to be callback-based
// for no behavioural gain.
dir = tizen.filesystem.resolve('wgt-private', function (d) { dir = d; }, function () { dir = null; }, 'rw');
} catch (e) { dir = null; }
function fileFor(name, create) { function tizenBackend() {
if (!dir) return null; var fsm = null;
try { return dir.resolve(name); } catch (e) { /* not there yet */ } try { fsm = (typeof tizen !== 'undefined' && tizen.filesystem) ? tizen.filesystem : null; }
if (!create) return null; catch (e) { fsm = null; }
try { return dir.createFile(name); } catch (e) { return null; }
} // The whole synchronous surface has to be present. Probing one method and assuming the rest is
// how a half-supported runtime ends up with a cache that half works.
var usable = !!(fsm &&
typeof fsm.openFile === 'function' &&
typeof fsm.toURI === 'function' &&
typeof fsm.pathExists === 'function');
function pathFor(contentId) { return DIR + '/' + contentId; }
return { return {
available: function () { return !!dir; }, available: function () { return usable; },
loadIndex: function () { loadIndex: function () {
try { return JSON.parse(localStorage.getItem(INDEX_KEY) || '{}'); } catch (e) { return {}; } try { return JSON.parse(localStorage.getItem(INDEX_KEY) || '{}'); } catch (e) { return {}; }
}, },
@ -286,10 +337,21 @@
body = []; body = [];
for (var i = 0; i < text.length; i++) body.push(text.charCodeAt(i) & 0xff); for (var i = 0; i < text.length; i++) body.push(text.charCodeAt(i) & 0xff);
} }
/*
* total comes from Content-Range on a 206 and from Content-Length ONLY on a 200.
*
* Content-Length on a 206 is the length of the CHUNK, so falling back to it would
* report a 1MB first slice of a 50MB video as a 1MB asset and the decision layer,
* correctly trusting its input, would mark it complete and hand the panel a truncated
* file to play. It is not hypothetical: a proxy that strips Content-Range, or a CORS
* context where the header is simply not readable, produces exactly this. 0 means
* "unknown", which the decision layer already treats as a stall.
*/
var isPartial = xhr.status === 206;
done({ done({
status: xhr.status, status: xhr.status,
start: m ? Number(m[1]) : 0, start: m ? Number(m[1]) : 0,
total: m ? Number(m[3]) : Number(xhr.getResponseHeader('Content-Length') || 0), total: m ? Number(m[3]) : (isPartial ? 0 : Number(xhr.getResponseHeader('Content-Length') || 0)),
validator: xhr.getResponseHeader('ETag') || xhr.getResponseHeader('Last-Modified') || null, validator: xhr.getResponseHeader('ETag') || xhr.getResponseHeader('Last-Modified') || null,
body: body body: body
}); });
@ -297,37 +359,55 @@
try { xhr.send(null); } catch (e) { done(null); } try { xhr.send(null); } catch (e) { done(null); }
}); });
}, },
/*
* A POSITIONED write, not an append.
*
* 'a' appends at EOF, so the moment the index and the file disagreed by so much as one chunk
* a power cut between the write and the index save, which is precisely the event this whole
* feature exists for every later chunk landed in the wrong place and the panel promoted a
* silently corrupt video. Seeking to the offset makes a repeated write IDEMPOTENT: replaying
* a chunk overwrites the same bytes with the same bytes, so the crash window stops mattering
* instead of being papered over.
*/
appendPart: function (contentId, body, offset) { appendPart: function (contentId, body, offset) {
var f = fileFor(contentId + '.part', true); if (!usable || !body || !body.length) return 0;
if (!f) return 0; var fh = null;
var written = 0;
// 'a' append mode, so a resumed transfer adds to what is already there instead of
// truncating it — which would make every attempt start from zero again.
f.openStream(offset > 0 ? 'a' : 'w', function (stream) {
try { stream.writeBytes(body); written = body.length; } finally { stream.close(); }
}, function () { written = 0; });
return written;
},
promotePart: function (contentId) {
var part = fileFor(contentId + '.part', false);
if (!part) return null;
try { try {
// Rename rather than copy: an atomic-enough swap, and a copy would need twice the space // 'w' truncates, which is what a fresh start means; 'rw' keeps what is there to write
// for a large video on a panel that may not have it. // into. makeParents:true creates wgt-private/st-media on first use.
part.moveTo(part.parent.fullPath + '/' + contentId, contentId, true, function () {}, function () {}); fh = fsm.openFile(pathFor(contentId), offset > 0 ? 'rw' : 'w', true);
} catch (e) { /* fall through and try to resolve it anyway */ } if (!fh) return 0;
var whole = fileFor(contentId, false); if (offset > 0) fh.seek(offset, 'BEGIN');
if (!whole) return null; fh.writeData(new Uint8Array(body));
return { path: whole.fullPath, uri: whole.toURI() }; if (typeof fh.flush === 'function') fh.flush();
return body.length;
} catch (e) {
return 0; // no space, no permission, no file — all "did not write"
} finally {
if (fh) { try { fh.close(); } catch (e2) { /* already gone */ } }
}
},
/*
* Nothing to promote the bytes have been written to their final name all along. This just
* answers with the URI, and only once the file is really there.
*/
promotePart: function (contentId) {
if (!usable) return null;
var p = pathFor(contentId);
try {
if (!fsm.pathExists(p)) return null;
return { path: p, uri: fsm.toURI(p) };
} catch (e) { return null; }
}, },
remove: function (contentId) { remove: function (contentId) {
if (!dir) return; if (!usable) return;
[contentId, contentId + '.part'].forEach(function (name) { var p = pathFor(contentId);
try { try {
var f = dir.resolve(name); if (!fsm.pathExists(p)) return;
if (f) dir.deleteFile(f.fullPath, function () {}, function () {}); // Still callback-based even in the 5.0 API, and best-effort by design: a delete that
} catch (e) { /* not present */ } // fails costs disk, while blocking on it would cost playback.
}); fsm.deleteFile(p, function () {}, function () {});
} catch (e) { /* not present, or refused */ }
} }
}; };
} }