source_remotecontrol_remoteProtocol.bs
' Transport-agnostic protocol constants + pure helpers for the remote-control receiver: the
' http-gate, the session-socket URL builder, reconnect backoff, and the client KeepAlive frame.
' No socket, no node, no m.global — fully unit-testable.
namespace remoteProtocol
' The http-gate. JellyRock can only open a ws:// socket to an http:// server — Roku can't do wss://
' (no socket TLS), so an https:// session stays uncontrolled (the future plugin long-poll path). Both
' the advertised capability (deviceCapabilities) and the receiver gate on this, so they never disagree.
function isHttpServer(serverUrl as dynamic) as boolean
if not isValidAndNotEmpty(serverUrl) then return false
return LCase(Left(serverUrl, 7)) = "http://"
end function
' Build the Jellyfin session-socket URL from the server URL + session credentials.
' Returns "" when the server isn't http:// (never downgrades an https:// token onto ws://)
' or when a credential is missing.
' http://host:port -> ws://host:port/socket?api_key=<token>&deviceId=<id>
'
' The `deviceId` query param does NOT bind the session — Jellyfin parses DeviceId from the
' Authorization header ONLY and ignores the query string entirely (verified in AuthorizationContext
' across 10.7 -> 10.11). It is kept because the endpoint accepts it and it is harmless, but the
' binding is done by the Authorization header RemoteControlTask sets on the upgrade handshake.
' Assuming otherwise is what split the session in #743. `api_key` IS load-bearing: it authenticates
' the upgrade, and keeping it means a proxy that strips Authorization degrades rather than fails.
function buildSocketUrl(serverUrl as dynamic, authToken as dynamic, deviceId as dynamic) as string
if not isHttpServer(serverUrl) then return ""
if not isValidAndNotEmpty(authToken) or not isValidAndNotEmpty(deviceId) then return ""
hostPort = Mid(serverUrl, 8) ' strip the leading "http://"
return "ws://" + hostPort + "/socket?api_key=" + authToken + "&deviceId=" + deviceId
end function
' --- HTTPS long-poll transport (#667) ---
' The plugin route prefix. JellyRock consumes the long-poll over TLS (roUrlTransfer) because Roku
' can't wss://. Unlike buildSocketUrl, these carry NO token in the URL — the caller attaches the
' standard Authorization header (baseRequest.buildAuthHeader), which already binds the session by
' DeviceId. See docs/architecture/remote-control-longpoll-contract.md.
const PLUGIN_ROUTE = "/JellyRock/RemoteControl"
' The long-poll wire-contract version this client implements. The plugin advertises its own
' contractVersion in the /info probe body; JellyRock refuses a mismatch and stays dark rather than
' risk acting on a command shape it might misread. See remote-control-longpoll-contract.md (Versioning).
const CONTRACT_VERSION = 1
' Build the plugin's long-poll command-channel URL for a server base URL. Returns "" for a blank
' server URL. waitMs is JellyRock's requested hold ceiling; the client-side transfer timeout is set
' longer so a 204 (empty hold) always arrives before the transfer itself times out.
'
' ack=1 is the at-least-once opt-in flag (contract v1, additive): it tells the plugin this client will
' acknowledge receipt, so the plugin retains delivered commands and redelivers any we didn't confirm.
' It is present on EVERY poll (including the first) — a plugin that doesn't understand it just ignores
' it and stays at-most-once. ackId is our cumulative ack: the last MessageId we durably received (""
' until we've received one, and omitted then). GUIDs are URL-safe, so no encoding is needed.
' https://host -> https://host/JellyRock/RemoteControl/poll?waitMs=<n>&ack=1[&ackId=<guid>]
function buildLongPollUrl(serverUrl as dynamic, waitMs as integer, ackId = "" as dynamic) as string
base = pluginBaseUrl(serverUrl)
if base = "" then return ""
url = base + "/poll?waitMs=" + waitMs.ToStr() + "&ack=1"
if isValidAndNotEmpty(ackId) then url = url + "&ackId=" + ackId
return url
end function
' Build the plugin presence/version probe URL (200 = plugin present, 404 = absent). Returns "" for a
' blank server URL. No token in the URL (auth is header-based).
' https://host -> https://host/JellyRock/RemoteControl/info
function buildProbeUrl(serverUrl as dynamic) as string
base = pluginBaseUrl(serverUrl)
if base = "" then return ""
return base + "/info"
end function
' Shared plugin route-prefix builder. Trims a single trailing slash on the server URL so we never
' emit "//". Returns "" for a missing/blank server URL.
function pluginBaseUrl(serverUrl as dynamic) as string
if not isValidAndNotEmpty(serverUrl) then return ""
base = serverUrl
if Right(base, 1) = "/" then base = Left(base, Len(base) - 1)
return base + PLUGIN_ROUTE
end function
' Extract the contractVersion from a /info probe body. Returns 0 for a blank/unparseable body, a
' non-object body, a missing key, or a non-numeric value — callers treat anything <> CONTRACT_VERSION
' as "no usable plugin". NEVER throws on a hostile/garbled body (it's network-controlled input): a
' string/object/bool contractVersion is rejected before any coercion that could raise at runtime.
function parseContractVersion(body as dynamic) as integer
if not isValidAndNotEmpty(body) then return 0
parsed = parseJson(body)
if not isValid(parsed) or type(parsed) <> "roAssociativeArray" then return 0
version = parsed.contractVersion
if not isValid(version) then return 0
if getInterface(version, "ifInt") <> invalid or getInterface(version, "ifFloat") <> invalid then return cint(version)
return 0
end function
' --- Cold-launch cast pairing (#668 P1) ---
' JellyRock reports its wake identity to the companion plugin so a CLOSED app can be cold-woken via
' ECP /launch. Sent to the SAME companion plugin as the long-poll channel, but works on http OR https
' (the wake is ECP, independent of the command-channel transport — an http server can wake a Roku just
' as well as an https one). The report carries NO DeviceId/UserId: the caller attaches the standard
' Authorization header and the plugin binds identity from the auth claim (body-untrusted, exactly like
' the /poll endpoint), so a hostile body can't spoof another device's pairing.
'
' Contract note — /pair is intentionally version-FREE (unlike /info+/poll, which carry CONTRACT_VERSION).
' It's a registration, not a command, so its skew-safety is the plugin's HTTP status contract, not a
' version field: an old plugin without the route 404s (-> producer-absent); a future BREAKING change
' MUST 400 old clients (or move the route so they 404) — never silently reinterpret a field. The body is
' additive-only (the plugin ignores unknown fields), and identity is auth-claim-bound. The client is
' fire-and-forget (SubmitSideEffect from Home.isFirstRun) and never reads the response, so it cannot be
' version-confused by construction. See docs/architecture/remote-control.md (Cold-launch pairing report).
' Build the pairing-report URL for a server base URL. Returns "" for a blank server url.
' https://host -> https://host/JellyRock/RemoteControl/pair
function buildPairUrl(serverUrl as dynamic) as string
base = pluginBaseUrl(serverUrl)
if base = "" then return ""
return base + "/pair"
end function
' Build the pairing-report JSON body from a device's LAN addresses + app identity. rokuIps is an array
' of IP strings (the values of roDeviceInfo.GetIPAddrs()); appId is roAppInfo.GetID() ("dev" when
' sideloaded, else the published channel id); isDev is roAppInfo.IsDev() (lets the plugin name a dev
' target distinctly). Returns "" when there are no usable IPs (nothing the plugin could wake) OR appId
' is blank, so the caller can skip a pointless report. The IPs are sanitized (see sanitizeIps).
function buildPairPayload(rokuIps as dynamic, appId as dynamic, isDev as dynamic) as string
ips = sanitizeIps(rokuIps)
if ips.count() = 0 then return ""
if not isValidAndNotEmpty(appId) then return ""
return FormatJson({
rokuIps: ips,
appId: appId,
isDev: (isValid(isDev) and isDev = true)
})
end function
' Sanitize a raw IP list into the addresses worth reporting: trim, drop blanks, drop loopback
' (127.x) and the unspecified address (0.0.0.0 — GetIPAddrs can surface a not-yet-configured
' interface as that), and dedupe (a Roku with both wired + wifi up can report the same address on
' more than one interface). Order-preserving. Always returns an array (never invalid), possibly empty.
function sanitizeIps(rokuIps as dynamic) as object
result = []
if not isValid(rokuIps) or type(rokuIps) <> "roArray" then return result
seen = {}
for each ip in rokuIps
if isValidAndNotEmpty(ip)
clean = ip.trim()
if clean <> "" and clean <> "0.0.0.0" and Left(clean, 4) <> "127." and not seen.doesExist(clean)
seen[clean] = true
result.push(clean)
end if
end if
end for
return result
end function
' Compose the pairing report as a fire-and-forget SideEffect request AA (for SubmitSideEffect). Returns
' invalid when there's nothing worth reporting — no server url, or no usable wake address / appId (see
' buildPairPayload) — so the caller can skip a pointless POST. NO auth in the AA: the SideEffectTask
' attaches the Authorization header (which binds device/user identity) and Content-Type. rokuIps comes
' from the caller's roDeviceInfo.GetIPAddrs() (a device call — kept out of this pure module).
function buildPairRequest(serverUrl as dynamic, rokuIps as dynamic, appId as dynamic, isDev as dynamic) as dynamic
url = buildPairUrl(serverUrl)
if url = "" then return invalid
body = buildPairPayload(rokuIps, appId, isDev)
if body = "" then return invalid
return { method: "POST", url: url, body: body }
end function
' Exponential reconnect backoff, 0-based attempt: 1s, 2s, 4s, 8s, 16s, then capped at 30s.
function nextBackoffMs(attempt as integer) as integer
ms = 1000
for i = 1 to attempt
ms = ms * 2
if ms >= 30000 then return 30000
end for
return ms
end function
' The client->server KeepAlive frame. Jellyfin's ForceKeepAlive asks the client to send
' these on the requested interval so the session isn't reaped by the inactivity timeout.
function keepAliveFrame() as string
return FormatJson({ MessageType: "KeepAlive" })
end function
end namespace