Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/tv-roku/components/MainScene.bs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ sub init()
' onBootDone right after the splash (see there) so it fires on EVERY launch — an unauthenticated launch
' never reaches the guide, and Roku's automated CBA can't complete our device-code sign-in.
m.global.addFields({ appLoaded: false })
' Session rejection: Api_onResponse bumps authRejected on a definitive 401 from /api/v1 (the stored token
' is expired / revoked / malformed). Handled HERE, once, for every screen — see onAuthRejected.
m.global.addFields({ authRejected: 0 })
m.global.observeField("authRejected", "onAuthRejected")
m.sessionExpired = false
m.pendingDeepLink = ""
m.top.observeField("deepLink", "onDeepLink")
' Deep link / voice / low-memory events run in a Task (off the render thread, and where Roku's cert
Expand Down Expand Up @@ -275,6 +280,25 @@ sub onSettingsExit()
route()
end sub

' The server rejected our bearer (tv-web parity: guide-screen.tsx forces sign-out on a 401). Drop ONLY the
' token — the server URL is still good — tear down the player, and route to Login with an explanation.
' Idempotent: the token is cleared on the first signal, so the other in-flight /api/v1 401s (favorites,
' packages, heartbeat…) land here as no-ops instead of re-routing again. A network failure never reaches
' here (no HTTP status), so a flaky connection can't cost the user their sign-in.
sub onAuthRejected()
if not Auth.hasToken() then return
Log.warn("[auth] session rejected by server (401) -> sign in again")
Auth.setToken("")
m.sessionExpired = true
if m.player <> invalid
m.player.layout = "off"
m.top.removeChild(m.player)
m.player = invalid
end if
m.probeStarted = false
route()
end sub

sub onSettingsReprobe()
startConnectionProbe()
end sub
Expand Down Expand Up @@ -431,6 +455,10 @@ sub showLogin()
m.top.appendChild(s)
s.observeField("done", "onLoginDone")
s.observeField("changeServer", "onLoginChangeServer")
if m.sessionExpired
s.notice = "Your session expired. Sign in again to continue."
m.sessionExpired = false
end if
s.setFocus(true)
m.screen = s
end sub
Expand Down
6 changes: 5 additions & 1 deletion apps/tv-roku/components/guide/GuideSidebar.bs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ sub init()
m.filterClip.clippingRect = [0, 0, SB_EXPANDED, m.vpH + 2 * SB_RING_ROOM]
m.filters.translation = [SB_RING_ROOM, SB_RING_ROOM]

refresh()
' Build the item list NOW, not on the first packages/lens change: `lens` starts at "all" (setting it to
' "all" again doesn't fire onLens) and `packages` only arrives if /api/v1/packages succeeds. Without this,
' a failed packages fetch (401 / offline) left m.items empty → onActivate dropped every OK, so Settings
' and Account were dead exactly when the user needed them to recover.
rebuild()
end sub

' Packages or lens changed → rebuild the filter list (Show All appears only when a filter is applied).
Expand Down
3 changes: 3 additions & 0 deletions apps/tv-roku/components/screens/Diagnostic.bs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ end sub

sub onManifestErr(err as object, _ctx as object)
Log.warn("[caps] manifest error: " + Log.trunc(err))
' A rejected session isn't "the matrix can't load" — MainScene is already routing to Login, and marking
' done here would skip the diagnostic for this server after the user signs back in.
if err <> invalid and err.kind = "auth" then return
' Don't nag on every login if the matrix can't load — mark done + route on.
Device.markCapsDone()
m.top.done = true
Expand Down
41 changes: 38 additions & 3 deletions apps/tv-roku/components/screens/Guide.bs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ sub init()
' (m.<id> for every XML node is auto-injected by bsc-plugin-auto-findnode.)
m.soundSeq = 0
m.refreshStarted = false
m.loadFailed = false
m.refreshTimer.observeField("fire", "onRefreshTick")

Theme.setFont(m.dayLabel, Theme.FONT_SEMI, GuideLayout.vw(32))
Expand Down Expand Up @@ -187,6 +188,10 @@ end sub
' Skipped while the sidebar is open (the grid is covered).
sub onRefreshTick()
if m.zone = "sidebar" then return
if m.loadFailed
retryLoad()
return
end if
p = Api.guide(180)
onThen(p, onGuideRefresh, m)
end sub
Expand Down Expand Up @@ -268,11 +273,38 @@ function lensEmptySub() as string
return "Once channels are set up on your server, they'll appear here."
end function

' First-load failure. The sidebar stays usable in every state (left → Settings / Account). An "auth" failure is
' owned app-wide (Api_onResponse → MainScene → Login), so here it only gets an interim message; every other
' kind keeps the credentials, says what actually failed, and retries — OK now, or the 60s timer.
sub onGuideErr(err as object, _ctx as object)
kind = ""
if err <> invalid and err.kind <> invalid then kind = err.kind
m.loadFailed = (kind <> "auth")
m.ghost.message = "Couldn't load the guide"
m.ghost.sub = "Check your connection and try again."
if kind = "auth"
m.ghost.sub = "Your session has expired. Taking you to sign in…"
else if kind = "forbidden"
m.ghost.sub = "Your account isn't allowed to view this guide. Ask your server's admin, or open Account to switch users."
else if kind = "server"
m.ghost.sub = "Your Airwave server had a problem. Press OK to try again."
else if kind = "network"
m.ghost.sub = "Can't reach your Airwave server. Check your connection, then press OK to try again."
else
m.ghost.sub = "Press OK to try again, or open Settings from the sidebar."
end if
showEmpty(true)
Log.warn("[guide] load error: " + Log.trunc(err))
' Keep retrying in the background (the same 60s cadence as the refresh) until the first load lands.
if m.loadFailed and not m.refreshStarted
m.refreshStarted = true
m.refreshTimer.control = "start"
end if
Log.warn("[guide] load error (" + kind + "): " + Log.trunc(err))
end sub

' Re-run the FIRST load (guide + favorites + packages) after a failure.
sub retryLoad()
m.loadFailed = false
load()
end sub

' T0 = now − 30min, snapped back to the nearest :31 / :01 half-hour boundary (matches tv-web/native).
Expand Down Expand Up @@ -379,11 +411,14 @@ function onKeyEvent(key as string, press as boolean) as boolean
end if

' From here on the keys act on channels. If the current lens filtered to nothing, only left (→ sidebar,
' to change the filter) does anything.
' to change the filter) does anything — plus OK to retry when the first load FAILED.
if m.channels = invalid or m.channels.count() = 0
if key = Keys.LEFT
enterSidebar()
playNav()
else if key = Keys.OK and m.loadFailed
playSelect()
retryLoad()
end if
return true
end if
Expand Down
4 changes: 4 additions & 0 deletions apps/tv-roku/components/screens/Login.bs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ sub init()
m.top.setFocus(true)
end sub

sub onNotice()
if m.top.notice <> "" then m.subtitle.text = m.top.notice
end sub

' ---- view state ---------------------------------------------------------------

sub showForm()
Expand Down
3 changes: 3 additions & 0 deletions apps/tv-roku/components/screens/Login.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
<interface>
<field id="done" type="boolean" alwaysNotify="true" />
<field id="changeServer" type="boolean" alwaysNotify="true" />
<!-- Why we're here, when it isn't a first sign-in (MainScene sets it after the server rejects the
stored session). Replaces the subtitle. -->
<field id="notice" type="string" onChange="onNotice" />
</interface>

<children>
Expand Down
24 changes: 20 additions & 4 deletions apps/tv-roku/components/settings/SettingsScreen.bs
Original file line number Diff line number Diff line change
Expand Up @@ -413,13 +413,29 @@ sub pageUser()
m.y = m.y + 148 + 8
mkSectionLabel("Account", false)
mkRow({ label: "Sign out", sublabel: "Sign this TV out of your account", cmd: "signOut" })
onThen(Api.session(), onSession, m)
p = Api.session()
onThen(p, onSession, m)
onCatch(p, onSessionErr, m)
end sub

' The identity card can't load (offline / server down) — say so instead of a forever "…"; Sign out (local)
' still works. (get-session isn't /api/v1, so a dead token resolves with no user rather than rejecting.)
sub onSessionErr(_err as object, _m as object)
if m.userCard = invalid then return
m.userCard.name.text = "Couldn't load your account"
m.userCard.email.text = "You can still sign out below."
end sub

sub onSession(data as object, _m as object)
if data = invalid or m.userCard = invalid then return
u = data.user
if u = invalid then return
if m.userCard = invalid then return
u = invalid
if data <> invalid then u = data.user
if u = invalid
' get-session answers 200 + null for a token the server no longer honours.
m.userCard.name.text = "Session expired"
m.userCard.email.text = "Sign out below, then sign in again."
return
end if
nm = u.name
if nm = invalid or nm = "" then nm = "Signed in"
m.userCard.name.text = nm
Expand Down
40 changes: 38 additions & 2 deletions apps/tv-roku/source/lib/api.bs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,36 @@ namespace Api
return FormatJson(b)
end function

' ---- error classification -------------------------------------------------

' Classify a rejected request's status so callers react to the KIND of failure, not a raw code. Every
' rejection carries this as `kind`:
' "network" — no HTTP response at all (status <= 0: unreachable, DNS, TLS, timeout)
' "auth" — 401: the server has no valid session for our bearer (expired / revoked / malformed)
' "forbidden" — 403: signed in, but not allowed (the /api/v1 per-channel gate) — creds stay valid
' "server" — 5xx
' "client" — any other 4xx
function errorKind(status as integer) as string
if status <= 0 then return "network"
if status = 401 then return "auth"
if status = 403 then return "forbidden"
if status >= 500 then return "server"
return "client"
end function

' Is this 401 a DEFINITIVE invalid-session verdict? Only for a bearer-carrying /api/v1 request: the
' server's `*` middleware (apps/server/src/rest.ts) 401s exactly when getSession() finds no session. The
' sign-in endpoints (/api/tv/auth/*, /api/auth/device/*) also 401 for a wrong password / pending code,
' which is NOT a dead session — so they're excluded. And only when the rejected bearer is the CURRENT
' token: a straggler still carrying a token we've already replaced must not sign out the new one.
function isSessionRejection(status as integer, req as object, currentToken as string) as boolean
if status <> 401 or req = invalid or req.url = invalid or currentToken = "" then return false
if Instr(1, req.url, "/api/v1/") = 0 then return false
h = req.headers
if h = invalid or h["Authorization"] = invalid then return false
return h["Authorization"] = "Bearer " + currentToken
end function

' ---- promise core -----------------------------------------------------------

' Fire an HTTP request against an ABSOLUTE url and return a Promise that resolves with the parsed
Expand Down Expand Up @@ -340,14 +370,20 @@ sub Api_onResponse(event as object)
if p = invalid then return

if resp = invalid
reject({ status: 0, message: "No response", body: invalid }, p)
reject({ status: 0, kind: "network", message: "No response", body: invalid }, p)
return
end if
if resp.ok = true
resolve(resp.body, p)
else
' A dead session is app-wide state, not a per-screen error: signal it ONCE here (MainScene observes
' authRejected → clears the token, keeps the server, routes to Login) so no screen can strand the
' user on it. The screen's own catch still runs (kind "auth") but must not try to recover on its own.
if Api.isSessionRejection(resp.status, task.request, Auth.token()) and m.global <> invalid and m.global.hasField("authRejected")
m.global.authRejected = m.global.authRejected + 1
end if
msg = "Request failed (" + Str(resp.status).Trim() + ")"
if resp.body <> invalid and type(resp.body) = "roAssociativeArray" and resp.body.error <> invalid then msg = resp.body.error
reject({ status: resp.status, message: msg, body: resp.body }, p)
reject({ status: resp.status, kind: Api.errorKind(resp.status), message: msg, body: resp.body }, p)
end if
end sub