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. The deviceId MUST match the DeviceId in baseRequest's
  ' auth header so the socket binds the SAME session the REST API uses.
  '   http://host:port  ->  ws://host:port/socket?api_key=<token>&deviceId=<id>
  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.
  '   https://host  ->  https://host/JellyRock/RemoteControl/poll?waitMs=<n>
  function buildLongPollUrl(serverUrl as dynamic, waitMs as integer) as string
    base = pluginBaseUrl(serverUrl)
    if base = "" then return ""
    return base + "/poll?waitMs=" + waitMs.ToStr()
  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

  ' 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