screentinker/brightsign/autorun.brs
ScreenTinker 60deefd992 BrightSign: ask the volumes for their size instead of trusting the mount check
GetStorageStatus() is documented for SD:/SSD:/USB: only, so it can never confirm
internal flash, and roStorageHotplug may be absent entirely. Gating the probe on
it made 'cannot say' read as 'no disk': a player with an NVMe reported 1025 MB,
which is the widget's cache quota arriving through the page-side fallback.

roStorageInfo is asked directly as a second pass, with the mount check kept first
so a removable volume still wins over internal flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 18:40:12 -05:00

1127 lines
57 KiB
Plaintext

' ScreenTinker — BrightSign player host
'
' The ScreenTinker player itself is the ordinary web player (server/player/index.html) running
' in an roHtmlWidget. This script is the HOST around it, and it exists for the things a page
' cannot do for itself:
'
' 1. OWN THE WIDGET LIFECYCLE. A page that calls location.reload() on a BrightSign does not
' reliably come back — observed in the field on 2026-07-28, where a ScreenTinker deploy
' reloaded every connected player and the BrightSign was the only one that never returned.
' So the page NEVER reloads itself here: it posts {type:"restart"} and this script tears the
' widget down and builds a new one. That is a restart the OS actually performs.
' 2. SURVIVE A DEAD SERVER. load-error retries with backoff and falls back to a local page,
' instead of leaving a white screen until someone power-cycles the box.
' 3. PERSIST IDENTITY across reboots and content changes, in the registry rather than in
' localStorage (which is tied to the page's origin and its storage quota).
' 4. REACH BRIGHTSCRIPT-ONLY CAPABILITIES on the page's behalf — video mode, a second output,
' and native BrightWall synchronisation — over the messageport bridge.
'
' Pair it with st-bridge.js, which is the JavaScript half of the same contract.
'
' SD card layout: autorun.brs st-bridge.js offline.html [screentinker.json]
'=== storage volume ==========================================================================
' WHERE we are running from is not a given. The obvious answer is the SD card, and every
' BrightSign example assumes it — but this player also boots FLASH:/autorun.brs straight out of
' internal eMMC, which is the ONLY path on a unit whose microSD interface is dead (ours has
' liquid corrosion on the card lines; the host controller probes at 400kHz and no card ever
' answers). Hard-coding "SD:" there means the script loads and then cannot find its own files.
'
' So ask the filesystem instead of assuming: whichever volume holds this script is the volume
' that holds everything else beside it.
Function StorageRoot() As String
' Which volume are we actually running from? Everything else is derived from this — the offline
' page, the widget's storage directory, the self-update paths — so getting it wrong points the
' whole player at a volume that may not physically exist.
'
' Probed in the order the OS itself searches for an autorun script (roStorageHotplug.GetStorages()
' documents ["USB1:/", "SD:/", "SD2:/", "SSD:/", "FLASH:/"]), so the answer matches the volume the
' player actually booted from. FLASH is last because it is the fallback of last resort: the unit
' this was developed on has a dead card slot and boots from internal flash, and an earlier version
' of this function knew only FLASH and SD — so fitting real storage to that player and moving the
' files onto it would have silently resolved every path to "SD:", a slot with nothing in it.
'
' ReadFile rather than a MatchFiles existence check: MatchFiles takes a DIRECTORY plus a pattern
' and returns nothing when the pattern contains a separator, which is why the helper further down
' this file never finds anything.
ba = CreateObject("roByteArray")
if ba.ReadFile("USB1:/autorun.brs") then return "USB1:"
if ba.ReadFile("SSD:/autorun.brs") then return "SSD:"
if ba.ReadFile("SD:/autorun.brs") then return "SD:"
if ba.ReadFile("SD2:/autorun.brs") then return "SD2:"
if ba.ReadFile("FLASH:/autorun.brs") then return "FLASH:"
return "SD:"
End Function
'=== configuration ==========================================================================
' Provisioning order: screentinker.json on the card (imaging a batch) > registry (set once at
' pairing, survives content updates) > the built-in default.
Function LoadConfig() As Object
cfg = {
server_url: "https://screentinker.com"
device_id: ""
sync_backend: "auto" ' auto | screentinker | brightsign
output_mode: "single" ' single | dual | clone
inspector: false
' Self-update of the host package. Defaults ON: a fleet that cannot be updated remotely is
' a fleet that needs a van. The DECISION is still the server's, and it refuses anything it
' cannot verify, so "on" does not mean "will apply whatever it is handed".
self_update: true
' Mirrors the Android beta channel. Off by default; an opted-in player also HOLDS a
' prerelease of its own core instead of being pulled back to the release.
allow_prerelease: false
}
' 1) registry
reg = CreateObject("roRegistrySection", "screentinker")
if reg.Exists("server_url") then cfg.server_url = reg.Read("server_url")
if reg.Exists("device_id") then cfg.device_id = reg.Read("device_id")
if reg.Exists("sync_backend") then cfg.sync_backend = reg.Read("sync_backend")
if reg.Exists("output_mode") then cfg.output_mode = reg.Read("output_mode")
if reg.Exists("self_update") then cfg.self_update = (reg.Read("self_update") = "1")
if reg.Exists("allow_prerelease") then cfg.allow_prerelease = (reg.Read("allow_prerelease") = "1")
' 2) a JSON file on the card wins — that is how a batch gets imaged without touching each box
ba = CreateObject("roByteArray")
if ba.ReadFile(StorageRoot() + "/screentinker.json") then
json = ParseJson(ba.ToAsciiString())
if json <> invalid then
if json.server_url <> invalid then cfg.server_url = json.server_url
if json.device_id <> invalid then cfg.device_id = json.device_id
if json.sync_backend <> invalid then cfg.sync_backend = json.sync_backend
if json.output_mode <> invalid then cfg.output_mode = json.output_mode
if json.inspector <> invalid then cfg.inspector = json.inspector
if json.self_update <> invalid then cfg.self_update = json.self_update
if json.allow_prerelease <> invalid then cfg.allow_prerelease = json.allow_prerelease
end if
end if
return cfg
End Function
Sub SaveRegistry(key As String, value As String)
reg = CreateObject("roRegistrySection", "screentinker")
reg.Write(key, value)
reg.Flush()
End Sub
'=== player URL =============================================================================
' Identity is carried in the URL so the page knows who it is before it has any storage of its
' own. serial is the stable hardware id; device_id is what ScreenTinker assigned at pairing.
Function PlayerUrl(cfg As Object, screen As Integer) As String
di = CreateObject("roDeviceInfo")
url = cfg.server_url + "/player?platform=brightsign"
url = url + "&serial=" + di.GetDeviceUniqueId()
url = url + "&model=" + di.GetModel()
url = url + "&sync_backend=" + cfg.sync_backend
if cfg.device_id <> "" then url = url + "&device_id=" + cfg.device_id
if screen > 1 then url = url + "&screen=" + Stri(screen).Trim()
return url
End Function
'=== widget construction ====================================================================
Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object) As Object
config = {
url: url
' THIS is what gates require("@brightsign/*"). Without it the bridge silently degrades to
' no-ops and the player loses identity AND restart delegation. ("BrightSign modules are
' actually part of the firmware, but in terms of usage they are identical to other Node.js
' 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
security_params: { websecurity: true }
hwz_default: "on" ' hardware z-order — video on its own plane
' An ABSOLUTE path on the volume we booted from. "/cache" carries no BrightSign drive
' specifier, so it resolves outside the writable volumes and the widget's local storage —
' the backing store a service worker, the Cache API and IndexedDB all need — has nowhere 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.
storage_path: StorageRoot() + "/cache" ' local storage, on the volume we booted from
' 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
mouse_enabled: false
}
if cfg.inspector then config.inspector_server = { port: 2999 }
w = CreateObject("roHtmlWidget", rect, config)
return w
End Function
' SyncManager will not work unless the PTP domain is set, and applying it needs a reboot. Done
' ONLY when this player is actually configured for native sync — a reboot on every boot would be
' a boot loop, and a player using our own protocol has no use for it.
'
' The read-before-write is what makes it safe: it reboots at most once, on the first boot after
' the mode is selected, and is a no-op forever after.
Sub EnsurePtpDomain(cfg As Object)
if cfg.sync_backend <> "brightsign" then return
regSec = CreateObject("roRegistrySection", "networking")
if regSec.Read("ptp_domain") = "0" then
print "[st] ptp_domain already 0"
else
print "[st] setting ptp_domain=0 for SyncManager — rebooting once to apply"
regSec.Write("ptp_domain", "0")
regSec.Flush()
RebootSystem()
end if
End Sub
' Capture what is ACTUALLY on screen, using the player's own Diagnostic Web Server.
'
' The page cannot do this itself. With hwz enabled, video decodes onto a hardware plane the DOM
' cannot see: drawImage(video) on a canvas returns a fully transparent image and throws nothing,
' so an in-page screenshot silently produces a blank frame. The DWS captures the real framebuffer,
' video included.
'
' It has to happen HERE rather than in the page for two reasons: the DWS is http on localhost and
' the player is served over https, so the page would be blocked as mixed content; and BrightScript
' is subject to neither CORS nor mixed-content rules. The credentials are the documented default —
' user "admin", password = the unit serial — which this script can read directly.
'
' ⚠️ Requires PRIMARY STORAGE. With no card or SSD fitted the endpoint answers
' "No primary storage found", because it writes the full-size capture to disk before returning the
' thumbnail. Reported back as-is rather than swallowed, so the dashboard can say why.
Sub TakeSnapshot(widget As Object, req As Object)
di = CreateObject("roDeviceInfo")
serial$ = di.GetDeviceUniqueId()
w% = 640
h% = 360
if req <> invalid and req.width <> invalid then w% = req.width
if req <> invalid and req.height <> invalid then h% = req.height
' BrightScript has NO escape sequences in string literals: "" does not mean an escaped quote,
' it ends one string and begins another, so `"{""width"":"` is three literals with no operator
' between them — a compile error that stops the WHOLE SCRIPT loading, not just this function.
' A quote has to come from Chr(34). This line is why the player booted to nothing:
' ScriptLoadError: Syntax Error. (compile error &h02) in SSD:/autorun.brs(196)
q$ = Chr(34)
body$ = "{" + q$ + "width" + q$ + ":" + Stri(w%).Trim() + "," + q$ + "height" + q$ + ":" + Stri(h%).Trim() + "}"
ut = CreateObject("roUrlTransfer")
if ut = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no roUrlTransfer" })
return
end if
ut.SetUrl("http://localhost/api/v1/snapshot/")
ut.SetUserAndPassword("admin", serial$)
ut.AddHeader("Content-Type", "application/json")
' PostFromStringWithRetry does not exist — calling it raised "Member function not found" from
' inside the event loop, i.e. a snapshot request took the whole player down. And the synchronous
' PostFromString() is no use either: it returns only a response CODE and discards the body, which
' is where the thumbnail is. The documented way to read a POST response is asynchronous, on a
' message port.
port = CreateObject("roMessagePort")
ut.SetPort(port)
if not ut.AsyncPostFromString(body$) then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "could not reach the local DWS" })
return
end if
' Bounded: a capture that never answers must not wedge the event loop that drives playback.
ev = Wait(20000, port)
if type(ev) <> "roUrlEvent" then
ut.AsyncCancel()
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "the local DWS did not answer" })
return
end if
resp$ = ev.GetString()
if resp$ = "" then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no response from the local DWS" })
return
end if
json = ParseJson(resp$)
if json = invalid or json.data = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "unparseable DWS response" })
return
end if
if json.data.error <> invalid then
' e.g. "No primary storage found." — pass the player's own words through; inventing a
' friendlier message here would hide the one fact that explains the failure.
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: json.data.error.message })
return
end if
r = json.data.result
if r = invalid or r.remotesnapshotthumbnail = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS returned no thumbnail" })
return
end if
widget.PostJSMessage({ type: "snapshot-result", ok: true, image: r.remotesnapshotthumbnail })
End Sub
' Rotate the OUTPUT, not the DOM.
'
' The web player rotates with a CSS transform, which is correct in a browser and wrong here: with
' hwz enabled the video decodes onto a hardware plane the DOM cannot transform, so a CSS rotation
' turns the images and widgets and leaves the video unrotated. Tizen hit this same wall and routes
' portrait video through AVPlay for exactly this reason.
'
' roVideoMode takes a transform — normal/90/180/270 — and rotating the screen rotates EVERYTHING,
' video included, because it happens below the compositor rather than above it.
'
' Reports success back to the page: if this fails, the page falls back to its CSS transform, which
' rotates most of the content rather than none of it. Silently doing neither would leave a portrait
' panel showing landscape content with no clue why.
Sub SetOrientation(widget As Object, o As String)
transform$ = "normal"
if o = "portrait" then transform$ = "90"
if o = "portrait-flipped" then transform$ = "270"
if o = "landscape-flipped" then transform$ = "180"
vm = CreateObject("roVideoMode")
if vm = invalid then
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "no roVideoMode" })
return
end if
' SetMode() takes ONE argument — a mode string. Passing a transform as a second argument was a
' "wrong number of function parameters" abort, so this Sub never reached its own reply and the
' 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.
' Implemented in BOS 9.0.15+; an older player simply has no method here and is told so.
' FindMemberFunction is the documented way to ask whether a method exists on this OS version —
' 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
print "[st] orientation: this OS has no SetScreenModes — the page keeps its CSS fallback"
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "SetScreenModes unavailable" })
return
end if
configs = vm.GetScreenModes()
if configs = invalid or configs.Count() = 0 then
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "no screen configuration" })
return
end if
' ⚠️ SetScreenModes REBOOTS the player when it changes the screen configuration. A playlist push
' repeats the current orientation on every update, so applying it unconditionally would reboot
' the display every time the server spoke to it. Only a real change is worth a reboot.
changed = false
for each c in configs
if c.transform <> transform$ then
c.transform = transform$
changed = true
end if
end for
if not changed then
print "[st] orientation already "; transform$; " — nothing to do"
widget.PostJSMessage({ type: "orientation-result", ok: true, transform: transform$ })
return
end if
' Tell the page BEFORE the call: the reboot may take the player out mid-sentence, and a display
' that rotates without ever confirming looks like the command was ignored.
widget.PostJSMessage({ type: "orientation-result", ok: true, transform: transform$, rebooting: true })
print "[st] orientation "; o; " -> transform "; transform$; " (the player will now reboot)"
sleep(1000)
vm.SetScreenModes(configs)
End Sub
'=== capability probe =======================================================================
' What this unit can actually do, answered by the only component that can see it.
'
' The page cannot determine any of this. There is no JavaScript API for device storage —
' @brightsign/storage exposes format/eject and nothing that enumerates volumes — so a player asked
' "do you have a disk?" could only guess. It matters because the DWS snapshot endpoint writes the
' full-size capture to disk before returning a thumbnail: with no card or SSD fitted it answers
' "No primary storage found", which is exactly what our XT245 does today. Declaring
' remote.screenshot on such a unit puts a button in the dashboard that cannot work.
'
' FLASH: is deliberately NOT counted. This player boots from internal flash because its card
' 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
' 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
result = { present: false, volume: "", free_mb: 0, total_mb: 0 }
' "USB:" not "USB1:" — the docs warn that GetStorageStatus() results are UNRELIABLE when called
' with a "USBn:" parameter, and list "USB:", "SD:", "SSD:", "SD2:/", "Flash:" as the drive
' strings it understands. One roStorageHotplug for the whole loop rather than one per volume.
hp = CreateObject("roStorageHotplug")
' 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.
'
' FLASH: is on this list and is NOT on GetStorageStatus()'s. The documented drive strings for
' that method are "SD:", "SSD:" and "USB:" — internal flash is simply not one of the things it
' can answer about, so a player booting from flash (which is how the XT245 in this office ran
' until an NVMe went in) can never be reported as mounted no matter what is actually there.
' Hence the roStorageInfo pass below: the mount check is treated as a hint, not a gate.
volumes = ["SSD:", "SD:", "SD2:", "USB:", "FLASH:"]
' Feature-gated (see HasFindMember). A player that cannot be probed simply keeps the static list,
' which is the answer the enumerator would have given anyway on every model we ship.
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
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
if hp <> invalid then
st = hp.GetStorageStatus(StatusDrive(raw))
if st <> invalid and st.mounted then mounted = true
end if
if mounted then
if FillStorage(result, v) then return result
end if
end for
' Nothing claimed to be mounted — which is not the same as nothing being there.
'
' GetStorageStatus() cannot answer for FLASH:, roStorageHotplug may not exist at all on an older
' build, and either way the whole probe hung on one call whose "no" was indistinguishable from
' "cannot say". roStorageInfo is the direct question: a volume that reports a non-zero size IS
' the disk, whatever the hotplug object thinks. The dashboard was showing 1025 MB for a player
' with an NVMe in it — the widget's own cache quota, reported through the fallback in
' st-bridge.js — because this function returned present:false and the page had nothing better.
'
' The mount check still runs FIRST: it is the more meaningful answer where it works, and it
' picks the removable volume ahead of internal flash on a player that has both.
for each raw in volumes
if FillStorage(result, TrimDrive(raw)) then return result
end for
return result
End Function
' Real device capacity for [drive], into [result]. True when the volume answered.
'
' The widget's storage quota — all the page can see via navigator.storage.estimate() — is the cache
' budget, not the disk, which is the entire reason the host is asked at all.
Function FillStorage(result As Object, drive As String) As Boolean
si = CreateObject("roStorageInfo", drive)
if si = invalid then return false
total = si.GetSizeInMegabytes()
if total = invalid or total <= 0 then return false
result.present = true
result.volume = drive
result.total_mb = total
free = si.GetFreeInMegabytes()
if free <> invalid and free >= 0 then result.free_mb = free
return true
End Function
' Everything the page cannot ask the hardware directly.
Sub SendProbeResult(widget As Object)
di = CreateObject("roDeviceInfo")
storage = StorageProbe()
osVer$ = ""
model$ = ""
if di <> invalid then
osVer$ = di.GetVersion()
model$ = di.GetModel()
end if
widget.PostJSMessage({
type: "probe-result"
storage_present: storage.present
storage_volume: storage.volume
storage_free_mb: storage.free_mb
storage_total_mb: storage.total_mb
os_version: osVer$
model: model$
})
End Sub
Function FullScreenRect() As Object
vm = CreateObject("roVideoMode")
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
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 ============================================================================
'
' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the
' player does: a truncated or half-applied autorun.brs is a dark panel and a site visit, because
' there is no app underneath to fall back to.
'
' The ordering below is the safety, and it is deliberate at every step:
'
' 1. Download to autorun.zip.part — never straight to autorun.zip. A file that is still
' downloading, or that stopped halfway, must never be a candidate for extraction.
' 2. Verify sha256 AND size before promoting. A captive portal that answers with a login page
' produces a perfectly well-formed small file; the size floor catches it, the hash catches
' everything else.
' 3. Only then promote: delete the .done marker, rename .part -> autorun.zip, reboot.
' The marker MUST go first — leaving it would make ApplyPendingPackage skip the new archive
' on the next boot and the update would silently never happen.
' 4. Extraction failure renames the archive to .bad rather than retrying forever. A zip that
' cannot be unpacked will not unpack on the tenth attempt either, and retrying it on every
' boot is a loop that looks exactly like a hardware fault.
'
' THE VERSION IS BAKED IN, not stored in a side file. A version record that can disagree with the
' code actually running is the OTA-loop condition in another guise: the player applies an update,
' reports the old version, is offered it again, forever. Stamped at build time by both
' scripts/build-autorun-zip.sh and server/lib/brightsign-package.js.
Function PackageVersion() As String
return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand)
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?
'
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
' published autozip.brs). It takes a FULL PATH, which is what every call site naturally has.
'
' MatchFiles is deliberately not used here. It is for LISTING a directory: it takes a directory plus
' a pattern, returns nothing when the pattern contains a separator, and — as this player
' demonstrated — does not reliably answer for a volume root like "SSD:/". The first version of this
' function passed a path as both arguments and could never return true at all; the second passed a
' directory and a bare name and still answered "no" for a file sitting right there. An existence
' check that is subtly wrong is worse than none, because every guard built on it silently opens.
Function FileExists(path As String) As Boolean
f = CreateObject("roReadFile", path)
return type(f) = "roReadFile"
End Function
' Unpack a package that is sitting on storage waiting to be applied. Runs BEFORE the widget so a
' pending update lands before the player starts, not halfway through a playlist.
'
' Note this duplicates autozip.brs on purpose. autozip.brs handles the FIRST install, where a bare
' card holds nothing but autorun.zip and the OS processes it. Once autorun.brs exists at the
' storage root the OS no longer auto-processes the archive — so from then on the host has to do it
' itself, or self-update would work exactly once.
Sub ApplyPendingPackage(root As String, buf As Object)
dir$ = root + "/"
zipPath$ = root + "/autorun.zip"
donePath$ = root + "/autorun.zip.done"
badPath$ = root + "/autorun.zip.bad"
stage$ = root + "/st-staging"
if not FileExists(dir$ + "autorun.zip") then return
if FileExists(dir$ + "autorun.zip.done") then return ' already unpacked; again is the boot loop
LogTo(buf, "update", "unpacking pending package")
package = CreateObject("roBrightPackage", zipPath$)
if package = invalid then
LogTo(buf, "update", "ERROR: archive unreadable — parking it as .bad")
MoveFile(zipPath$, badPath$)
return
end if
' ⚠️ Unpack() DELETES everything already in its target directory: "Providing a destination path
' of SD:/ will wipe all preexisting files from the card". Unpacking straight to the volume root
' would therefore erase this player's provisioning and its entire content pool on every update —
' the update would work and the display would come back empty and unpaired.
'
' So it goes to a staging directory of its own, and the files are moved into place afterwards.
' The wipe is then a FEATURE: it clears any half-extracted remains of a previous attempt.
CreateDirectory(stage$)
package.Unpack(stage$ + "/")
' Unpack() returns Void, so success is proven by looking for what should now exist rather than
' by testing a return value that was never there.
if not FileExists(stage$ + "/autorun.brs") then
LogTo(buf, "update", "ERROR: extract produced no autorun.brs — parking it as .bad")
MoveFile(zipPath$, badPath$)
return
end if
' screentinker.json is deliberately NOT copied over: it carries THIS player's provisioning
' (server URL, device id), and the copy inside a package carries the build's defaults. Letting
' an update overwrite it would re-point or unpair the display as a side effect of a routine
' upgrade — silently, and on every player at once.
moved% = 0
for each name in MatchFiles(stage$, "*")
if name <> "screentinker.json" then
if MoveFile(stage$ + "/" + name, root + "/" + name) then moved% = moved% + 1
end if
end for
LogTo(buf, "update", "installed " + Stri(moved%).Trim() + " file(s)")
if not MoveFile(zipPath$, donePath$) then
' Refusing to reboot without the marker: we would extract and reboot forever.
LogTo(buf, "update", "ERROR: could not mark done — not rebooting")
return
end if
LogTo(buf, "update", "package applied — rebooting into it")
sleep(2000)
RebootSystem()
End Sub
' Ask the server what to do, and do exactly that. The DECISION lives on the server
' (server/lib/brightsign-update.js, which is unit-tested); this only executes it. Re-implementing
' the version comparison here would put the prerelease trap somewhere it cannot be tested.
Sub CheckPackageUpdate(cfg As Object, root As String)
if cfg.server_url = "" then return
' A package already staged and waiting for its apply-reboot is not a reason to fetch another.
' Observed on hardware: the periodic check fired in the gap between staging and rebooting and
' pulled the whole archive down a second time. Harmless here; on a metered or marginal link it
' is the same waste this product spent a release eliminating everywhere else.
if FileExists(root + "/autorun.zip") then
print "[st-update] a package is already staged — waiting for it to apply"
return
end if
partPath$ = root + "/autorun.zip.part"
reg = CreateObject("roRegistrySection", "screentinker")
attempts% = 0
if reg.Exists("pkg_attempts") then attempts% = Val(reg.Read("pkg_attempts"))
url$ = cfg.server_url + "/api/brightsign/package?version=" + PackageVersion()
url$ = url$ + "&attempts=" + Stri(attempts%).Trim()
if cfg.allow_prerelease then url$ = url$ + "&allow_prerelease=1"
xfer = CreateObject("roUrlTransfer")
if xfer = invalid then return
xfer.SetUrl(url$)
xfer.EnablePeerVerification(true)
body$ = xfer.GetToString()
if body$ = "" then return ' unreachable server: keep running what works
manifest = ParseJson(body$)
if manifest = invalid then return
if manifest.action = invalid then return
if manifest.action <> "download" then
if manifest.reason <> invalid then print "[st-update] no action: "; manifest.reason
return
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
' Any earlier partial is deleted first: resuming into an existing file would concatenate two
' downloads into something that hashes to neither.
if FileExists(root + "/autorun.zip.part") then DeleteFile(partPath$)
dl = CreateObject("roUrlTransfer")
if dl = invalid then return
dl.SetUrl(cfg.server_url + manifest.url)
dl.EnablePeerVerification(true)
if dl.GetToFile(partPath$) <> 200 then
print "[st-update] download failed"
RecordPackageAttempt(reg, attempts% + 1)
DeleteFile(partPath$)
return
end if
' Verify before promoting. This is the gate that stops a truncated file becoming the boot script.
if not VerifyPackage(partPath$, manifest.sha256, manifest.size) then
print "[st-update] VERIFICATION FAILED — discarding, staying on "; PackageVersion()
RecordPackageAttempt(reg, attempts% + 1)
DeleteFile(partPath$)
return
end if
' Promote. Marker first — see the ordering note above.
if FileExists(root + "/autorun.zip.done") then DeleteFile(root + "/autorun.zip.done")
if FileExists(root + "/autorun.zip") then DeleteFile(root + "/autorun.zip")
if not MoveFile(partPath$, root + "/autorun.zip") then
print "[st-update] ERROR: could not stage the package — staying put"
RecordPackageAttempt(reg, attempts% + 1)
return
end if
' A clean attempt counter, so the next version starts from zero rather than inheriting this
' version's failures and being refused before it is ever tried.
RecordPackageAttempt(reg, 0)
print "[st-update] staged "; manifest.version; " — rebooting to apply"
sleep(2000)
RebootSystem()
End Sub
Sub RecordPackageAttempt(reg As Object, n As Integer)
if reg = invalid then return
reg.Write("pkg_attempts", Stri(n).Trim())
reg.Flush()
End Sub
' sha256 + size. Both matter: the hash proves the bytes are the ones we were promised, the size
' floor catches an error page or captive-portal login saved under the package's name.
Function VerifyPackage(path As String, expected As String, expectedSize As Integer) As Boolean
' Guard the arguments before the type declarations do it for us: a manifest missing sha256 or
' size passes `invalid` into an `As String`/`As Integer` parameter, which is a runtime error at
' the CALL — before any check inside the function could help.
if expected = "" then return false
' roByteArray + roHashGenerator. The previous version used roFileSystem.Stat/OpenInputFile and
' roMessageDigest — all three are Roku objects that do not exist on BrightSign, so this function
' returned false unconditionally and every self-update failed verification and burned an
' attempt. The package is tens of kilobytes, so reading it whole is cheaper than the streaming
' loop it replaces.
ba = CreateObject("roByteArray")
if ba = invalid then return false
if not ba.ReadFile(path) then
print "[st-update] package unreadable at "; path
return false
end if
size% = ba.Count()
if size% < 1024 then
print "[st-update] package is implausibly small ("; size%; " bytes)"
return false
end if
if expectedSize > 0 and size% <> expectedSize then
print "[st-update] size mismatch: got "; size%; " expected "; expectedSize
return false
end if
hg = CreateObject("roHashGenerator", "sha256")
if hg = invalid then return false
digest = hg.Hash(ba)
if digest = invalid then return false
' Hash() answers with an roByteArray, not a string.
return LCase(digest.ToHexString()) = LCase(expected)
End Function
'=== host diagnostics =======================================================================
'
' Everything the host knows that the PAGE cannot ask for, routed into the same channels the other
' players already use: the dashboard log stream, the device-event feed, and the heartbeat telemetry.
'
' This exists because of a specific, expensive afternoon. A single bad string literal stopped this
' script compiling, and the only evidence anywhere was a line on a serial console — the server saw a
' player that simply never appeared, and the display showed nothing. Every other player reports its
' own failures; this one printed them to a cable. A panel on a wall has no cable.
'
' The pre-widget phase is the part that matters most and is the part that is hardest to reach: the
' storage probe, a pending package being applied, the video mode being set, all happen before there
' is a page to talk to. Those lines accumulate in a buffer and are flushed the moment the widget
' exists, so the boot story arrives even though it happened before anyone could listen.
' Append a diagnostic to the pre-widget buffer AND put it on the console. The buffer is an roArray
' created in Main and passed down; BrightScript has no global store (no GetGlobalAA here), and
' threading it explicitly beats the alternative of losing the boot entirely.
Sub LogTo(buf As Object, tag As String, message As String)
print "[st-"; tag; "] "; message
if buf <> invalid then
if buf.Count() < 200 then ' a boot that logs 200 lines has a worse problem
buf.Push({ tag: tag, message: message })
end if
end if
End Sub
' Send one diagnostic to the page, which forwards it to the server as a device:log line.
Sub HostLog(widget As Object, tag As String, message As String)
print "[st-"; tag; "] "; message
if widget = invalid then return
widget.PostJSMessage({ type: "host-log", tag: tag, level: "i", message: message })
End Sub
' Hand the buffered boot diagnostics to the page in one go, oldest first.
Sub FlushLog(widget As Object, buf As Object)
if widget = invalid or buf = invalid then return
for each line in buf
widget.PostJSMessage({ type: "host-log", tag: line.tag, level: "i", message: line.message })
end for
buf.Clear()
End Sub
' A device EVENT rather than a log line: these land in the incident feed the dashboard shows against
' a display, so they are reserved for things an operator would want explained — a reboot, a network
' change, the player falling over.
Sub HostEvent(widget As Object, event As String, reason As String, detail As String)
print "[st-event] "; event; " "; reason; " "; detail
if widget = invalid then return
widget.PostJSMessage({ type: "host-event", event: event, reason: reason, detail: detail })
End Sub
' The facts only the host can see. The page has no API for any of this: @brightsign/storage exposes
' format and eject, not volumes; there is no JavaScript route to the uptime, the wired IP, the video
' mode actually in force, or which volume the player booted from.
Sub SendHostTelemetry(widget As Object, cfg As Object)
if widget = invalid then return
t = { type: "host-telemetry" }
' Seconds since boot. A display that reports a small uptime every time it is polled is
' rebooting in a loop, which is otherwise indistinguishable from a healthy one.
up = UpTime(0)
if up <> invalid then t.uptime_seconds = Int(up)
di = CreateObject("roDeviceInfo")
if di <> invalid then
t.model = di.GetModel()
t.os_version = di.GetVersion()
end if
' The wired address. Empty string when nothing is configured, per the documented contract.
nc = CreateObject("roNetworkConfiguration", 0)
if nc <> invalid then
cur = nc.GetCurrentConfig()
if cur <> invalid and cur.ip4_address <> invalid and cur.ip4_address <> "" then
t.local_ip = cur.ip4_address
end if
end if
vm = CreateObject("roVideoMode")
if vm <> invalid then t.video_mode = vm.GetMode()
' Which volume is actually in use, and how much of it is left. The page's
' navigator.storage.estimate() reports the widget's CACHE QUOTA, not the disk — a panel can
' report gigabytes free while the volume holding them is full.
st = StorageProbe()
if st.present then
t.storage_volume = st.volume
t.storage_free_mb = st.free_mb
t.storage_total_mb = st.total_mb
end if
t.boot_volume = StorageRoot()
t.package_version = PackageVersion()
widget.PostJSMessage(t)
End Sub
'=== main ===================================================================================
Sub Main()
' Diagnostics from before there is a page to send them to. Flushed the moment the widget exists.
boot = CreateObject("roArray", 32, true)
cfg = LoadConfig()
LogTo(boot, "boot", "host " + PackageVersion() + " from " + StorageRoot() + " -> " + cfg.server_url)
' Crash dumps land here if the widget ever falls over — cheap, and the only forensic trail
' available on a panel nobody can reach.
dir = CreateDirectory(StorageRoot() + "/brightsign-dumps")
' Must happen BEFORE the widget starts: it can reboot.
EnsurePtpDomain(cfg)
' A package staged by a previous run lands here, before anything is on screen. Doing it after
' the widget started would mean rebooting out of a playing playlist, and the panel would blink
' mid-content for a reason nobody watching could explain.
ApplyPendingPackage(StorageRoot(), boot)
port = CreateObject("roMessagePort")
' Second output. The XC5 family exposes more than one HDMI connector (XC2055 dual, XC4055
' quad). Do NOT trust the series-level spec blurb here: it credits the whole XT5 family with
' "dual HDMI outputs", but an XT245 in hand is single-output — that phrase appears to cover
' HDMI in + out. Check the individual model, not the family.
'
' Every single-output model must fall through this cleanly. GetResX/GetResY only ever
' describe output 1, so a second widget is created ONLY when the config asks for it — an
' unsupported model then keeps working as a normal single-screen player rather than failing
' to start. Screen 2 loads the SAME player with &screen=2, so the server can hand it its own
' playlist ("dual" = independent) or the same one ("clone").
dual = (cfg.output_mode = "dual" or cfg.output_mode = "clone")
rect = FullScreenRect()
widget = MakeWidget(PlayerUrl(cfg, 1), rect, port, cfg)
widget.Show()
' NOT flushed here. Show() only creates the widget — the page has not been fetched, let alone
' run st-bridge.js, so there is nothing on the other end of PostJSMessage yet and every line
' would go into the void. Buffered instead until the page says hello (its `probe` message,
' 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
if dual then
' ⚠️ There is NO per-widget output selector. roHtmlWidget takes a rectangle and nothing else:
' its init parameters have no `screen`/`output` key, and neither does the JavaScript
' HtmlWidgetParams. A second output is addressed by BUILDING ONE TALL CANVAS with
' 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
retries = 0
lastBeat = CreateObject("roTimespan")
lastBeat.Mark()
' Update check runs AFTER the widget is up, deliberately. A slow or unreachable server must
' never delay first frame — content on screen is the job, updating is housekeeping. It also
' runs on a timer rather than only at boot, because a panel that is never power-cycled would
' otherwise never see an update at all.
lastPkgCheck = CreateObject("roTimespan")
lastPkgCheck.Mark()
PKG_CHECK_MS = 6 * 60 * 60 * 1000 ' 6h: this replaces the boot script, so rarely is right
if cfg.self_update then CheckPackageUpdate(cfg, StorageRoot())
' A watchdog on TOP of load-error: a page can load fine and then wedge (dead socket, JS
' exception, decoder stall) without the OS ever reporting an error. st-bridge.js posts a
' heartbeat every 30s; three missed beats and we rebuild the widget. This is the difference
' between a panel that recovers on its own and one that needs a site visit.
' Seconds first, milliseconds derived: the diagnostic message needs an INTEGER to format, and
' dividing at the call site would hand Stri a float.
WATCHDOG_S = 120
WATCHDOG_MS = WATCHDOG_S * 1000
lastHostTel = CreateObject("roTimespan")
lastHostTel.Mark()
HOST_TEL_MS = 60000
while true
msg = wait(5000, port)
if type(msg) = "roHtmlWidgetEvent" then
data = msg.GetData()
if data.reason = "load-finished" then
retries = 0
lastBeat.Mark()
print "[st] player loaded"
else if data.reason = "load-error" then
' Back off, then fall back to the local page so the screen says something
' truthful instead of showing white. The local page keeps retrying the server.
retries = retries + 1
' The key is `uri` on a load-error; `url` belongs to download-request. Printing
' the wrong one meant the single diagnostic that names the failing resource always
' printed "invalid".
' data.uri is already a String per the event contract, so no conversion is wanted:
' Str() is for numbers and would abort the event loop. Guarded because a missing key
' yields invalid, and assigning invalid to a $-typed name is a runtime error.
uri$ = ""
if data.uri <> invalid then uri$ = data.uri
HostEvent(widget, "app_error", "load-error", "attempt " + Stri(retries).Trim() + ": " + uri$)
sleep(ChooseBackoff(retries))
if retries >= 3 then
' The server URL rides along so the fallback page can name it on screen and
' keep probing it — the page has no other way to learn where home is.
widget = RebuildWidget(widget, "file:/" + StorageRoot() + "/offline.html?server=" + cfg.server_url, rect, port, cfg)
else
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
end if
else if data.reason = "message" then
m = data.message
lastBeat.Mark()
' A missing member is `invalid`, and comparing invalid to a literal is a TYPE
' MISMATCH that aborts the script — taking the whole player down with it. Every
' field is existence-checked before it is compared.
if m = invalid or m.type = invalid then
' nothing addressable in this message
else if m.type = "heartbeat" then
' nothing to do — marking the timespan above IS the handling
else if m.type = "restart" then
' The page asks to be restarted (deploy, version change, unrecoverable
' error). NEVER let the page do this with location.reload().
print "[st] restart requested: "; m.reason
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
else if m.type = "identity" then
' Pairing completed in the page — persist it where a reboot can find it.
' clear:true is the operator reset; the registry must forget the display or
' the next boot re-adopts it and the reset silently does nothing.
if m.clear <> invalid and m.clear = true then
SaveRegistry("device_id", "")
cfg.device_id = ""
end if
if m.device_id <> invalid then
SaveRegistry("device_id", m.device_id)
cfg.device_id = m.device_id
end if
if m.server_url <> invalid then
SaveRegistry("server_url", m.server_url)
cfg.server_url = m.server_url
end if
else if m.type = "probe" then
' Asked once during boot, before the player registers: the answer decides which
' controls the dashboard is allowed to offer for this display.
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
if m.orientation <> invalid then SetOrientation(widget, m.orientation)
else if m.type = "snapshot" then
TakeSnapshot(widget, m)
else if m.type = "set-video-mode" then
vm = CreateObject("roVideoMode")
if m.mode <> invalid then vm.SetMode(m.mode)
else if m.type = "set-sync-backend" then
' The server decided which protocol this deployment uses (see
' server/lib/sync-backend.js). Persist it so a cold boot with no network
' still starts in the right mode.
if m.backend <> invalid then
SaveRegistry("sync_backend", m.backend)
cfg.sync_backend = m.backend
end if
else if m.type = "reboot" then
print "[st] reboot requested"
RebootSystem()
end if
end if
end if
' watchdog
if lastBeat.TotalMilliseconds() > WATCHDOG_MS then
' Reported as a crash, because that is what it is from the floor: the page stopped
' answering and the host restarted it. Previously this healed the panel in silence, so a
' display rebuilding itself every two minutes looked identical to one that was fine.
HostEvent(widget, "crash", "watchdog", "no heartbeat for " + Stri(WATCHDOG_S).Trim() + "s — rebuilt the widget")
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
lastBeat.Mark()
end if
' Host facts, on the same cadence as the package check is cheap but far too slow to be
' useful; every telemetry tick would be too chatty. A minute is what the dashboard shows.
if lastHostTel.TotalMilliseconds() > HOST_TEL_MS then
lastHostTel.Mark()
SendHostTelemetry(widget, cfg)
end if
' Periodic package check. Marked BEFORE the call, not after: a check that blocks on a slow
' server would otherwise be retried immediately on the next tick and hammer it.
if cfg.self_update and lastPkgCheck.TotalMilliseconds() > PKG_CHECK_MS then
lastPkgCheck.Mark()
CheckPackageUpdate(cfg, StorageRoot())
end if
end while
End Sub
Function ChooseBackoff(retries As Integer) As Integer
if retries <= 1 then return 5000
if retries = 2 then return 15000
if retries = 3 then return 30000
return 60000
End Function
' Tear the old widget down explicitly before building the new one. Dropping the reference alone
' leaves the old widget composited and holding its decoder until GC gets to it, which shows up
' as two players fighting over the screen.
Function RebuildWidget(old As Object, url As String, rect As Object, port As Object, cfg As Object) As Object
if old <> invalid then
old.Hide()
old = invalid
end if
w = MakeWidget(url, rect, port, cfg)
w.Show()
return w
End Function