From e5e28bbafe776fdd89ce1fc81288f4a021ca6747 Mon Sep 17 00:00:00 2001 From: Beckness Date: Sat, 17 Oct 2026 17:48:45 -0400 Subject: [PATCH] fix(tv-roku): recover from a rejected session instead of stranding the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expired/revoked bearer left the Roku client unusable: every /api/v1 call 401'd, the guide showed "Check your connection", and Settings/Account in the sidebar silently did nothing — so the user could not even sign out. - GuideSidebar built its item list only on a packages/lens change. With /api/v1/packages failing (and `lens` never changing from its "all" default), m.items stayed empty and onActivate dropped every OK. Build it in init so the action circles always work. - Classify request failures centrally (Api.errorKind → network / auth / forbidden / server / client) and carry `kind` on every rejection. - A definitive 401 from a bearer /api/v1 request (for the current token) is signalled once from Api_onResponse; MainScene clears only the token, keeps the server URL, tears down the player and routes to Login with a "session expired" notice. Idempotent across the parallel 401s; network failures never clear credentials. Matches tv-web's forced sign-out on a guide 401. - Guide first-load failures say what failed and retry (OK, or the 60s timer) instead of dead-ending; 403 is explained without touching credentials. - Diagnostic no longer marks caps done when the manifest 401s. - Settings → User shows "Session expired" / load errors instead of "…". Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/tv-roku/components/MainScene.bs | 28 +++++++++++++ apps/tv-roku/components/guide/GuideSidebar.bs | 6 ++- apps/tv-roku/components/screens/Diagnostic.bs | 3 ++ apps/tv-roku/components/screens/Guide.bs | 41 +++++++++++++++++-- apps/tv-roku/components/screens/Login.bs | 4 ++ apps/tv-roku/components/screens/Login.xml | 3 ++ .../components/settings/SettingsScreen.bs | 24 +++++++++-- apps/tv-roku/source/lib/api.bs | 40 +++++++++++++++++- 8 files changed, 139 insertions(+), 10 deletions(-) diff --git a/apps/tv-roku/components/MainScene.bs b/apps/tv-roku/components/MainScene.bs index 246fce10..f71cf4d0 100644 --- a/apps/tv-roku/components/MainScene.bs +++ b/apps/tv-roku/components/MainScene.bs @@ -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 @@ -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 @@ -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 diff --git a/apps/tv-roku/components/guide/GuideSidebar.bs b/apps/tv-roku/components/guide/GuideSidebar.bs index 5fec9b4d..2ef42207 100644 --- a/apps/tv-roku/components/guide/GuideSidebar.bs +++ b/apps/tv-roku/components/guide/GuideSidebar.bs @@ -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). diff --git a/apps/tv-roku/components/screens/Diagnostic.bs b/apps/tv-roku/components/screens/Diagnostic.bs index d23bceef..8b592e76 100644 --- a/apps/tv-roku/components/screens/Diagnostic.bs +++ b/apps/tv-roku/components/screens/Diagnostic.bs @@ -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 diff --git a/apps/tv-roku/components/screens/Guide.bs b/apps/tv-roku/components/screens/Guide.bs index 73722eff..9b3c96cc 100644 --- a/apps/tv-roku/components/screens/Guide.bs +++ b/apps/tv-roku/components/screens/Guide.bs @@ -16,6 +16,7 @@ sub init() ' (m. 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)) @@ -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 @@ -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). @@ -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 diff --git a/apps/tv-roku/components/screens/Login.bs b/apps/tv-roku/components/screens/Login.bs index fec861bb..8a31a6fc 100644 --- a/apps/tv-roku/components/screens/Login.bs +++ b/apps/tv-roku/components/screens/Login.bs @@ -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() diff --git a/apps/tv-roku/components/screens/Login.xml b/apps/tv-roku/components/screens/Login.xml index 3df5c42c..539da8ce 100644 --- a/apps/tv-roku/components/screens/Login.xml +++ b/apps/tv-roku/components/screens/Login.xml @@ -9,6 +9,9 @@ + + diff --git a/apps/tv-roku/components/settings/SettingsScreen.bs b/apps/tv-roku/components/settings/SettingsScreen.bs index 9a1b667f..a273ee64 100644 --- a/apps/tv-roku/components/settings/SettingsScreen.bs +++ b/apps/tv-roku/components/settings/SettingsScreen.bs @@ -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 diff --git a/apps/tv-roku/source/lib/api.bs b/apps/tv-roku/source/lib/api.bs index b13b44d8..1c67f005 100644 --- a/apps/tv-roku/source/lib/api.bs +++ b/apps/tv-roku/source/lib/api.bs @@ -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 @@ -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