From e78644b7485406d487861d8d7e477c1773f72855 Mon Sep 17 00:00:00 2001 From: SHRIVES93 <75397028+SHRIVES93@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:47:58 -0400 Subject: [PATCH 1/5] fix(tvos): persist server configuration outside purgeable caches AsyncStorage 2.2.0 stores tvOS values in NSCachesDirectory, where the OS can remove saved settings. Failed hydration also previously mounted the entry route with incomplete state and sent configured users back to onboarding. - Store the six small session/onboarding values in an allowlisted UserDefaults Expo module; acknowledge writes and migrate surviving cache values with deletion markers. Preserve the keychain-free tvOS workaround and other platforms' existing storage backends. - Gate routing on successful hydration and provide remote/touch Retry on storage failure. Surface failed saves, logout, and server changes. - Clear both credential interfaces on logout while retaining the server. Invalidate only the current bearer on 401 and serialize token updates so stale responses cannot erase a new login; retain state on transient failures. - Document migration, storage limits, and the required native binary update. Validation: native storage and session regressions pass. The user confirms all physical Apple TV session persistence and recovery acceptance tests pass. --- apps/tv-native/.gitignore | 2 +- apps/tv-native/app/_layout.tsx | 46 ++++++++-- apps/tv-native/app/index.tsx | 6 +- apps/tv-native/app/login.tsx | 20 +++- apps/tv-native/app/settings/server.tsx | 6 +- apps/tv-native/app/settings/user.tsx | 6 +- apps/tv-native/app/setup.tsx | 9 +- .../tv-native/modules/session-store/README.md | 29 ++++++ .../session-store/expo-module.config.json | 4 + .../ios/PersistentSessionStore.swift | 45 +++++++++ .../session-store/ios/PrivacyInfo.xcprivacy | 8 ++ .../session-store/ios/SessionStore.podspec | 13 +++ .../ios/SessionStoreModule.swift | 16 ++++ .../modules/session-store/package.json | 5 + apps/tv-native/package.json | 5 + apps/tv-native/src/lib/api.ts | 13 ++- apps/tv-native/src/lib/auth.ts | 54 ++++++++--- apps/tv-native/src/lib/cred-store.ts | 92 +++++-------------- apps/tv-native/src/lib/device.ts | 17 ++-- apps/tv-native/src/lib/session-route.ts | 6 ++ apps/tv-native/src/lib/session-storage.ts | 50 ++++++++++ apps/tv-native/src/lib/startup.ts | 11 +++ 22 files changed, 349 insertions(+), 114 deletions(-) create mode 100644 apps/tv-native/modules/session-store/README.md create mode 100644 apps/tv-native/modules/session-store/expo-module.config.json create mode 100644 apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift create mode 100644 apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy create mode 100644 apps/tv-native/modules/session-store/ios/SessionStore.podspec create mode 100644 apps/tv-native/modules/session-store/ios/SessionStoreModule.swift create mode 100644 apps/tv-native/modules/session-store/package.json create mode 100644 apps/tv-native/src/lib/session-route.ts create mode 100644 apps/tv-native/src/lib/session-storage.ts create mode 100644 apps/tv-native/src/lib/startup.ts diff --git a/apps/tv-native/.gitignore b/apps/tv-native/.gitignore index a0103d39..477e76f4 100644 --- a/apps/tv-native/.gitignore +++ b/apps/tv-native/.gitignore @@ -3,7 +3,7 @@ dist/ web-build/ android/ -ios/ +/ios/ *.orig.* # Expo Router / TypeScript generated diff --git a/apps/tv-native/app/_layout.tsx b/apps/tv-native/app/_layout.tsx index 988592c4..f835e1ed 100644 --- a/apps/tv-native/app/_layout.tsx +++ b/apps/tv-native/app/_layout.tsx @@ -4,17 +4,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { Component, type ReactNode, useEffect, useState } from "react"; -import { Dimensions, Platform, View } from "react-native"; +import { Dimensions, Platform, Pressable, Text, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { BootSplash } from "@/components/boot-splash"; import { PlayerProvider } from "@/features/watch/player-context"; -import { getToken, hasServerUrl, loadSession } from "@/lib/auth"; +import { getToken, hasServerUrl } from "@/lib/auth"; import { loadDevice } from "@/lib/device"; import { hydrateAudioMode } from "@/lib/audio-pref"; -import { notifyInputActivity, useAndroidBack, useHardwareKeyInput, useTVInput } from "@/lib/input"; +import { LAYER, useKeyLayer, notifyInputActivity, useAndroidBack, useHardwareKeyInput, useTVInput } from "@/lib/input"; import { hydrateNetwork, probeConnection } from "@/lib/plex-connection"; +import { initializeApp } from "@/lib/startup"; import { C } from "@/lib/theme"; // Startup diagnostic — isTV drives UI_SCALE + the focusable/native-focus gating; the window dp size + @@ -60,6 +61,8 @@ class SplashBoundary extends Component<{ children: ReactNode }, { failed: boolea */ export default function RootLayout() { const [ready, setReady] = useState(false); + const [storageError, setStorageError] = useState(false); + const [attempt, setAttempt] = useState(0); const [bootDone, setBootDone] = useState(false); // Input sources → the one dispatcher (tv-web's model). `useTVInput` = the TV remote D-pad (TV builds @@ -68,21 +71,52 @@ export default function RootLayout() { useTVInput(); useHardwareKeyInput(); useAndroidBack(); + const retry = () => { + setStorageError(false); + setAttempt((value) => value + 1); + }; + useKeyLayer({ + id: "startup-retry", + priority: LAYER.BASE, + active: storageError, + onKey(event) { + if (event.key !== "ok") return false; + retry(); + return true; + }, + }); useEffect(() => { - void Promise.all([loadSession(), loadDevice(), hydrateNetwork(), hydrateAudioMode()]).finally(() => { + let active = true; + void initializeApp([loadDevice, hydrateNetwork, hydrateAudioMode]).then(() => { + if (!active) return; setReady(true); // Refresh which Plex connection this device can actually reach (local → remote → relay) so // off-network playback streams from the right URL. Non-blocking; the first media request uses // the hydrated last-known value until this resolves. Only when we have a session to probe with. if (hasServerUrl() && getToken()) void probeConnection(); + }).catch(() => { + if (active) setStorageError(true); }); - }, []); + return () => { + active = false; + }; + }, [attempt]); // Hydrate session/device BEFORE mounting the app (route guards read the session synchronously). While // loading, a plain dark screen — it matches the splash background, so the hand-off is seamless. This is the // original, known-good boot; the splash is a pure overlay ON TOP of the mounted app. - if (!ready) return ; + if (!ready) return ( + + {storageError && <> + Could not read saved settings. + Retry to load your server and session. + + Retry + + } + + ); return ( notifyInputActivity()}> diff --git a/apps/tv-native/app/index.tsx b/apps/tv-native/app/index.tsx index 9bddb26d..aa112846 100644 --- a/apps/tv-native/app/index.tsx +++ b/apps/tv-native/app/index.tsx @@ -1,6 +1,6 @@ import { Redirect } from "expo-router"; -import { getToken, hasServerUrl } from "@/lib/auth"; +import { sessionRoute } from "@/lib/session-route"; /** * Entry gate — the native analogue of tv-web's `main.tsx` routing + the `_auth` guard: @@ -9,7 +9,5 @@ import { getToken, hasServerUrl } from "@/lib/auth"; * - signed in → the guide (home) */ export default function Index() { - if (!hasServerUrl()) return ; - if (!getToken()) return ; - return ; + return ; } diff --git a/apps/tv-native/app/login.tsx b/apps/tv-native/app/login.tsx index 2c53d238..ebe3d4de 100644 --- a/apps/tv-native/app/login.tsx +++ b/apps/tv-native/app/login.tsx @@ -54,7 +54,12 @@ export default function Login() { const res = await plexLink.poll(started.pinId); if (res.status === "ok") { stopPolling(); - await setToken(res.token); + try { + await setToken(res.token); + } catch { + reset("Couldn't save your session. Please try again."); + return; + } void probeConnection(); // pick the reachable Plex connection for this network router.replace("/guide"); } else if (res.status === "expired") { @@ -94,7 +99,12 @@ export default function Login() { }); if (tok?.access_token) { stopPolling(); - await setToken(tok.access_token); + try { + await setToken(tok.access_token); + } catch { + reset("Couldn't save your session. Please try again."); + return; + } void probeConnection(); // pick the reachable Plex connection for this network router.replace("/guide"); return; @@ -105,10 +115,10 @@ export default function Login() { }, (data.interval ?? 5) * 1000); }, [router]); - // "Change server" — drop the onboarded URL and bounce through the entry gate → onboarding. No token - // exists yet at login (mirrors settings/server.tsx's changeServer, minus the setToken(null)). + // Change server also clears any surviving bearer/Expo credentials before onboarding. const changeServer = useCallback(() => { - void clearServerUrl().then(() => router.replace("/")); + void setToken(null).then(clearServerUrl).then(() => router.replace("/")) + .catch(() => setError("Couldn't change server. Please try again.")); }, [router]); // D-pad zone machine. On Android TV our native key module consumes the D-pad and routes it here diff --git a/apps/tv-native/app/settings/server.tsx b/apps/tv-native/app/settings/server.tsx index 18771c21..f55fa5eb 100644 --- a/apps/tv-native/app/settings/server.tsx +++ b/apps/tv-native/app/settings/server.tsx @@ -22,9 +22,12 @@ const NETWORK_LABEL: Record = { export default function ServerSettings() { const router = useRouter(); const [armChange, setArmChange] = useState(false); + const [storageError, setStorageError] = useState(false); const changeServer = () => { // Sign out + drop the stored server, then bounce through the entry gate → onboarding. - void Promise.all([setToken(null), clearServerUrl()]).then(() => router.replace("/")); + setStorageError(false); + void setToken(null).then(clearServerUrl).then(() => router.replace("/")) + .catch(() => setStorageError(true)); }; const [network, setNetwork] = useState(getNetwork()); @@ -65,6 +68,7 @@ export default function ServerSettings() { return ( + {storageError && Couldn't change server. Please try again.} diff --git a/apps/tv-native/app/settings/user.tsx b/apps/tv-native/app/settings/user.tsx index 826b093c..5cdc0758 100644 --- a/apps/tv-native/app/settings/user.tsx +++ b/apps/tv-native/app/settings/user.tsx @@ -21,8 +21,11 @@ export default function UserSettings() { const user = session?.user as { name?: string | null; email?: string; image?: string | null; role?: string | null } | undefined; const [armOut, setArmOut] = useState(false); + const [storageError, setStorageError] = useState(false); const signOut = () => { - void setToken(null).then(() => router.replace("/login")); + setStorageError(false); + void setToken(null).then(() => router.replace("/login")) + .catch(() => setStorageError(true)); }; const { sel } = useSettingsPage(1, () => { if (armOut) signOut(); @@ -35,6 +38,7 @@ export default function UserSettings() { return ( + {storageError && Couldn't sign out. Please try again.} diff --git a/apps/tv-native/app/setup.tsx b/apps/tv-native/app/setup.tsx index ae672350..dc789106 100644 --- a/apps/tv-native/app/setup.tsx +++ b/apps/tv-native/app/setup.tsx @@ -32,8 +32,13 @@ export default function Setup() { const inputRef = useRef(null); const save = async (target: string) => { - await setServerUrl(target); - router.replace("/login"); + try { + await setServerUrl(target); + router.replace("/login"); + } catch { + setChecking(false); + setError("Couldn't save your server address. Please try again."); + } }; const connect = async () => { diff --git a/apps/tv-native/modules/session-store/README.md b/apps/tv-native/modules/session-store/README.md new file mode 100644 index 00000000..17e71d5d --- /dev/null +++ b/apps/tv-native/modules/session-store/README.md @@ -0,0 +1,29 @@ +# Apple TV session storage + +AsyncStorage 2.2.0 uses `NSCachesDirectory` on tvOS and warns that its contents may +be removed. This local Expo module stores six small values in the app's +UserDefaults instead: server URL, bearer token, the two Better Auth Expo values, +device ID, and the server-specific capability onboarding flag. + +The JS adapter migrates surviving AsyncStorage values before routing. An empty +string marks a migrated missing/deleted value, preventing old cache data from +restoring a logged-out session or a previous server. Already-purged values cannot +be recovered: affected users must configure/login once after upgrading. + +Synchronous Expo functions let both credential interfaces read the same store. +Writes acknowledge UserDefaults synchronization before setup/login navigates. +Read/write errors reach the caller; startup provides a remote/touch Retry action +and never interprets a read error as a new installation. Logout clears bearer, +cookie and cached session values while retaining the server. Change Server also +clears the URL. Only a 401 for the current bearer session requires login; +network, 403 and 5xx failures retain configuration and credentials. + +This preserves the existing keychain-free tvOS workaround. UserDefaults is not +encrypted storage, just as the former AsyncStorage fallback was not encrypted. +iOS/iPadOS and Android retain SecureStore for credentials and AsyncStorage for +configuration. No session expiry or server authorization rules change. + +Each value is restricted to 32 KiB and keys are allowlisted, keeping these six +values below tvOS's 512 KiB UserDefaults warning threshold. The pod bundles the +required UserDefaults privacy declaration. A new native binary is required; +an OTA JavaScript update alone cannot supply this module. diff --git a/apps/tv-native/modules/session-store/expo-module.config.json b/apps/tv-native/modules/session-store/expo-module.config.json new file mode 100644 index 00000000..daea4db7 --- /dev/null +++ b/apps/tv-native/modules/session-store/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["apple"], + "apple": { "modules": ["SessionStoreModule"] } +} diff --git a/apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift b/apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift new file mode 100644 index 00000000..7b637366 --- /dev/null +++ b/apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift @@ -0,0 +1,45 @@ +import Foundation + +/// AsyncStorage uses purgeable caches on tvOS. Store only these small configuration/session +/// values in UserDefaults, without the keychain used by SecureStore on other platforms. +final class PersistentSessionStore { + private let defaults: UserDefaults + private let prefix = "airwave.session." + private let keys: Set = [ + "cg-tv-server-url", "cg-tv-token", "cg_cookie", "cg_session_data", + "cg-device-id", "cg-caps-done" + ] + + init(defaults: UserDefaults = .standard) { self.defaults = defaults } + + private func storageKey(_ key: String) throws -> String { + guard keys.contains(key) else { throw failure(1, "Unsupported session storage key") } + return prefix + key + } + + private func failure(_ code: Int, _ message: String) -> NSError { + NSError(domain: "SessionStore", code: code, userInfo: [NSLocalizedDescriptionKey: message]) + } + + func getItem(_ key: String) throws -> String? { + let value = defaults.object(forKey: try storageKey(key)) + guard let value else { return nil } + guard let string = value as? String else { throw failure(2, "Invalid session storage value") } + return string + } + + func setItem(_ key: String, value: String) throws { + // Six bounded strings stay below tvOS's 512 KB UserDefaults warning threshold. + guard value.utf8.count <= 32 * 1024 else { throw failure(3, "Session storage value is too large") } + let key = try storageKey(key) + let previous = defaults.object(forKey: key) + defaults.set(value, forKey: key) + // A synchronous acknowledgement lets setup/login await persistence before navigating. + // This is intentionally stronger than an asynchronous UserDefaults/Settings write. + guard defaults.synchronize() else { + if let previous { defaults.set(previous, forKey: key) } + else { defaults.removeObject(forKey: key) } + throw failure(4, "Could not persist session storage") + } + } +} diff --git a/apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy b/apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..7105f70e --- /dev/null +++ b/apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy @@ -0,0 +1,8 @@ + + + + NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasonsCA92.1 + + diff --git a/apps/tv-native/modules/session-store/ios/SessionStore.podspec b/apps/tv-native/modules/session-store/ios/SessionStore.podspec new file mode 100644 index 00000000..25ef7ed2 --- /dev/null +++ b/apps/tv-native/modules/session-store/ios/SessionStore.podspec @@ -0,0 +1,13 @@ +Pod::Spec.new do |s| + s.name = 'SessionStore' + s.version = '0.0.0' + s.summary = 'Keychain-free persistent session storage for Apple TV.' + s.author = 'Airwave' + s.homepage = 'https://github.com/Quixomatic/Airwave' + s.platforms = { :ios => '15.1', :tvos => '15.1' } + s.source = { git: '' } + s.dependency 'ExpoModulesCore' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.source_files = '**/*.swift' + s.resource_bundles = { 'SessionStore_privacy' => ['PrivacyInfo.xcprivacy'] } +end diff --git a/apps/tv-native/modules/session-store/ios/SessionStoreModule.swift b/apps/tv-native/modules/session-store/ios/SessionStoreModule.swift new file mode 100644 index 00000000..57c9075b --- /dev/null +++ b/apps/tv-native/modules/session-store/ios/SessionStoreModule.swift @@ -0,0 +1,16 @@ +import ExpoModulesCore + +public class SessionStoreModule: Module { + private let store = PersistentSessionStore() + + public func definition() -> ModuleDefinition { + Name("AirwaveSessionStore") + Function("getItem") { (key: String) throws -> String? in + try self.store.getItem(key) + } + Function("setItem") { (key: String, value: String) throws -> Bool in + try self.store.setItem(key, value: value) + return true + } + } +} diff --git a/apps/tv-native/modules/session-store/package.json b/apps/tv-native/modules/session-store/package.json new file mode 100644 index 00000000..f72ae0d3 --- /dev/null +++ b/apps/tv-native/modules/session-store/package.json @@ -0,0 +1,5 @@ +{ + "name": "airwave-session-store", + "version": "0.0.0", + "private": true +} diff --git a/apps/tv-native/package.json b/apps/tv-native/package.json index 7e1e7b04..81fdafc3 100644 --- a/apps/tv-native/package.json +++ b/apps/tv-native/package.json @@ -52,5 +52,10 @@ "devDependencies": { "@react-native-tvos/config-tv": "^0.1.6", "tailwindcss": "^3.4.19" + }, + "expo": { + "autolinking": { + "nativeModulesDir": "./modules" + } } } diff --git a/apps/tv-native/src/lib/api.ts b/apps/tv-native/src/lib/api.ts index dd0000b2..452ef636 100644 --- a/apps/tv-native/src/lib/api.ts +++ b/apps/tv-native/src/lib/api.ts @@ -1,4 +1,6 @@ -import { getServerUrl, getToken } from "./auth"; +import { router } from "expo-router"; + +import { getServerUrl, getToken, invalidateToken } from "./auth"; import { getNetwork } from "./plex-connection"; /** @@ -21,7 +23,8 @@ export class ApiError extends Error { async function request(path: string, init: RequestInit = {}): Promise { const token = getToken(); - const res = await fetch(`${getServerUrl()}${path}`, { + const server = getServerUrl(); + const res = await fetch(`${server}${path}`, { ...init, headers: { "Content-Type": "application/json", @@ -30,6 +33,12 @@ async function request(path: string, init: RequestInit = {}): Promise { }, }); if (!res.ok) { + // Only reject the current credential on 401; retain it on network/5xx/permissions failures. + // A late response from a previous login or server must not invalidate the new session. + if (res.status === 401 && token && token === getToken() && server === getServerUrl()) { + const invalidated = await invalidateToken(token, server); + if (invalidated && server === getServerUrl() && !getToken()) router.replace("/login"); + } const body = (await res.json().catch(() => null)) as { error?: string } | null; throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`); } diff --git a/apps/tv-native/src/lib/auth.ts b/apps/tv-native/src/lib/auth.ts index e4e9ed81..c5c76dd9 100644 --- a/apps/tv-native/src/lib/auth.ts +++ b/apps/tv-native/src/lib/auth.ts @@ -1,6 +1,5 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; - -import { CredStore, hydrateSyncCreds } from "./cred-store"; +import { CredStore, clearSyncCreds, hydrateSyncCreds } from "./cred-store"; +import { SessionStorage } from "./session-storage"; /** * Native equivalent of tv-web's `auth-client` + `server-url`, minus better-auth's React client: @@ -8,8 +7,8 @@ import { CredStore, hydrateSyncCreds } from "./cred-store"; * (a) where the server is and (b) the token. * * - The **bearer token** is a credential → `CredStore` (Keychain/Keystore via SecureStore, except on the - * Apple TV where it falls back to AsyncStorage — see `cred-store.ts` for the tvOS release-crash reason). - * - The **server URL** and other prefs are not secret → `AsyncStorage`. + * Apple TV where it uses UserDefaults — see `cred-store.ts` for the tvOS release-crash reason). + * - The **server URL** is not secret → `SessionStorage` (UserDefaults on tvOS, AsyncStorage elsewhere). * * These are read once at startup into module-level values (mirroring tv-web, where `SERVER_URL` is * evaluated at module load and the app re-initialises after onboarding). `loadSession()` hydrates @@ -25,16 +24,22 @@ const BAKED = (process.env.EXPO_PUBLIC_SERVER_URL ?? "").replace(/\/+$/, ""); let serverUrl = BAKED; let token: string | null = null; +let tokenWrites: Promise = Promise.resolve(); /** Hydrate the in-memory session from storage. Call once before rendering the app. */ export async function loadSession(): Promise { - const [storedUrl, storedToken] = await Promise.all([ - AsyncStorage.getItem(SERVER_KEY), + const [urlResult, tokenResult, expoResult] = await Promise.allSettled([ + SessionStorage.getItem(SERVER_KEY), CredStore.getItemAsync(TOKEN_KEY), - // Warm the Apple-TV sync credential cache (for better-auth's expoClient) before any auth screen renders. + // Migrate Apple-TV Expo credentials before any auth screen renders. hydrateSyncCreds(), ]); - if (storedUrl) serverUrl = storedUrl.replace(/\/+$/, ""); + if (urlResult.status !== "fulfilled" || tokenResult.status !== "fulfilled" || expoResult.status !== "fulfilled") { + throw new Error("Could not read saved session"); + } + const storedUrl = urlResult.value; + const storedToken = tokenResult.value; + serverUrl = storedUrl ? storedUrl.replace(/\/+$/, "") : BAKED; token = storedToken ?? null; } @@ -45,21 +50,40 @@ export function hasServerUrl(): boolean { return serverUrl !== ""; } export async function setServerUrl(raw: string): Promise { - serverUrl = normalizeServerUrl(raw); - await AsyncStorage.setItem(SERVER_KEY, serverUrl); + const next = normalizeServerUrl(raw); + await SessionStorage.setItem(SERVER_KEY, next); + serverUrl = next; } export async function clearServerUrl(): Promise { + await SessionStorage.removeItem(SERVER_KEY); serverUrl = BAKED; - await AsyncStorage.removeItem(SERVER_KEY); } export function getToken(): string | null { return token; } +function writeToken(next: string | null, rejected?: { token: string; server: string }): Promise { + const write = tokenWrites.then(async () => { + if (rejected && (token !== rejected.token || serverUrl !== rejected.server)) return false; + if (next) await CredStore.setItemAsync(TOKEN_KEY, next); + else { + await clearSyncCreds(); + await CredStore.deleteItemAsync(TOKEN_KEY); + } + token = next; + return true; + }); + // Serialize deletion with a concurrent login; failed writes must not block a later retry. + tokenWrites = write.catch(() => {}); + return write; +} + export async function setToken(next: string | null): Promise { - token = next; - if (next) await CredStore.setItemAsync(TOKEN_KEY, next); - else await CredStore.deleteItemAsync(TOKEN_KEY); + await writeToken(next); +} + +export function invalidateToken(rejectedToken: string, rejectedServer: string): Promise { + return writeToken(null, { token: rejectedToken, server: rejectedServer }); } /** Coerce user input into a base URL (add a scheme if missing, drop a trailing slash) — tv-web parity. */ diff --git a/apps/tv-native/src/lib/cred-store.ts b/apps/tv-native/src/lib/cred-store.ts index e06db99b..fd0953e2 100644 --- a/apps/tv-native/src/lib/cred-store.ts +++ b/apps/tv-native/src/lib/cred-store.ts @@ -1,48 +1,15 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; import * as SecureStore from "expo-secure-store"; -import { Platform } from "react-native"; -/** - * Credential storage with a SecureStore-shaped async interface (`getItemAsync` / `setItemAsync` / - * `deleteItemAsync`), so it's a drop-in for `expo-secure-store` (used by our token store AND by - * `@better-auth/expo`'s `expoClient`). - * - * ## Why this exists — the Apple TV release-build crash - * On the **Apple TV** we deliberately DON'T use `expo-secure-store`. tvOS's keychain is heavily restricted, - * and reading it (`searchKeyChain`) at startup throws — which, on a **release + New-Architecture (bridgeless)** - * build, ABORTS the app before the UI ever appears: - * `*** Terminating app due to uncaught exception 'RCTFatalException: non-std C++ exception'` - * This is React Native framework bug **facebook/react-native#54859** (iOS/tvOS 26 only, release only, fine in - * debug): a throwing async **void** TurboModule method isn't caught in `ObjCTurboModule::performVoidMethodInvocation`, - * so the re-thrown exception on a background queue kills the process. `expo-secure-store` is one of the named - * triggers, and our `loadSession()` hits it at launch — matching the crash exactly. - * - * tvOS keychain persistence is unreliable anyway, so on Apple TV we back credentials with **AsyncStorage**. - * iPad/iOS and Android keep the real Keychain/Keystore via SecureStore (unaffected — this is a no-op there). - * - * NOTE: the `[cred-store]` console logs below fire on the Apple TV only, so you can SEE the keychain-free path - * being exercised in the Metro console (dev client). They log storage KEYS and hit/miss — never token values. - */ -const isAppleTV = Platform.OS === "ios" && Platform.isTV; - -// eslint-disable-next-line no-console -const log = (msg: string) => console.log(`[cred-store] ${msg}`); +import { isAppleTV, SessionStorage, TVSessionStorage } from "./session-storage"; +/** Keep the Apple TV keychain-free: SecureStore access previously crashed release builds + * with RN's New Architecture (facebook/react-native#54859). UserDefaults replaces the + * purgeable AsyncStorage fallback; iOS/iPadOS and Android retain SecureStore. */ export const CredStore = isAppleTV ? { - getItemAsync: async (key: string) => { - const v = await AsyncStorage.getItem(key); - log(`(AppleTV·AsyncStorage) getItemAsync "${key}" → ${v == null ? "miss" : "hit"}`); - return v; - }, - setItemAsync: (key: string, value: string) => { - log(`(AppleTV·AsyncStorage) setItemAsync "${key}"`); - return AsyncStorage.setItem(key, value); - }, - deleteItemAsync: (key: string) => { - log(`(AppleTV·AsyncStorage) deleteItemAsync "${key}"`); - return AsyncStorage.removeItem(key); - }, + getItemAsync: (key: string) => SessionStorage.getItem(key), + setItemAsync: (key: string, value: string) => SessionStorage.setItem(key, value), + deleteItemAsync: (key: string) => SessionStorage.removeItem(key), } : { getItemAsync: (key: string) => SecureStore.getItemAsync(key), @@ -50,42 +17,29 @@ export const CredStore = isAppleTV deleteItemAsync: (key: string) => SecureStore.deleteItemAsync(key), }; -// ── Synchronous credential store for @better-auth/expo's expoClient ────────────────────────────────────── -// better-auth-expo reads its storage SYNCHRONOUSLY (`getItem(key): string | null`) — used by the device-code -// login (`login.tsx`) and `useSession()` (`settings/user.tsx`). On iPad/Android that's expo-secure-store's -// sync accessors (real Keychain/Keystore). On the Apple TV we must NOT touch the keychain at all (same -// RN #54859 crash — logging in or opening Settings → User would hit it), and AsyncStorage has no sync API — -// so we back it with an in-memory cache, hydrated once from AsyncStorage at startup (`hydrateSyncCreds()`, -// awaited in `loadSession()` before any auth screen renders) and write-through to AsyncStorage so the -// better-auth session persists across launches. -const syncCache = new Map(); +// @better-auth/expo 1.6 uses these two keys with storagePrefix: "cg". Migrate only +// those values, rather than copying all device preferences into credential storage. +const SYNC_KEYS = ["cg_cookie", "cg_session_data"]; -/** Populate the Apple-TV sync cache from AsyncStorage. No-op elsewhere. Awaited at startup in loadSession. */ export async function hydrateSyncCreds(): Promise { if (!isAppleTV) return; - try { - const keys = await AsyncStorage.getAllKeys(); - const entries = await AsyncStorage.multiGet(keys); - for (const [k, v] of entries) if (v != null) syncCache.set(k, v); - log(`(AppleTV) hydrated SyncCredStore cache: ${syncCache.size} key(s) [${[...syncCache.keys()].join(", ")}]`); - } catch (e) { - log(`(AppleTV) hydrateSyncCreds FAILED: ${String(e)}`); + const results = await Promise.allSettled(SYNC_KEYS.map((key) => SessionStorage.getItem(key))); + if (results.some((result) => result.status === "rejected")) { + throw new Error("Could not read saved credentials"); + } +} + +export async function clearSyncCreds(): Promise { + const results = await Promise.allSettled(SYNC_KEYS.map((key) => CredStore.deleteItemAsync(key))); + if (results.some((result) => result.status === "rejected")) { + throw new Error("Could not clear saved credentials"); } } +/** The Expo auth client needs synchronous reads. Both credential interfaces now use + * the same persistent tvOS store, without a stale cache or fire-and-forget writes. */ export const SyncCredStore = isAppleTV - ? { - getItem: (key: string): string | null => { - const v = syncCache.get(key) ?? null; - log(`(AppleTV) SyncCredStore.getItem "${key}" → ${v == null ? "miss" : "hit"}`); - return v; - }, - setItem: (key: string, value: string) => { - log(`(AppleTV) SyncCredStore.setItem "${key}"`); - syncCache.set(key, value); - void AsyncStorage.setItem(key, value); - }, - } + ? TVSessionStorage : { getItem: (key: string) => SecureStore.getItem(key), setItem: (key: string, value: string) => SecureStore.setItem(key, value), diff --git a/apps/tv-native/src/lib/device.ts b/apps/tv-native/src/lib/device.ts index 56622c3c..40925846 100644 --- a/apps/tv-native/src/lib/device.ts +++ b/apps/tv-native/src/lib/device.ts @@ -1,4 +1,4 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; +import { SessionStorage } from "./session-storage"; import { mpvDisplay } from "@airwave/mpv-player"; import { Dimensions, Platform, PixelRatio } from "react-native"; @@ -8,8 +8,8 @@ import { getServerUrl } from "./auth"; * Per-device identity + the capability-diagnostic "done" flag, ported from tv-web's `device.ts`. * The profile is measured device-side but stored in the SERVER's DB per `deviceId`, and the done * flag is keyed by SERVER URL so the diagnostic re-runs on a server switch (a new server has no - * profile for this device). Storage is AsyncStorage; values are hydrated once at startup into - * module vars so the getters are sync (mirroring tv-web, where localStorage is sync). + * profile for this device). Storage is UserDefaults on tvOS, AsyncStorage elsewhere. Values are + * hydrated once at startup into module vars so getters are sync (mirroring tv-web). */ const DEVICE_ID_KEY = "cg-device-id"; const CAPS_DONE_KEY = "cg-caps-done"; @@ -23,12 +23,12 @@ function gen(): string { /** Hydrate device identity + caps-done. Call once at startup (alongside loadSession). */ export async function loadDevice(): Promise { - _deviceId = (await AsyncStorage.getItem(DEVICE_ID_KEY)) ?? ""; + _deviceId = (await SessionStorage.getItem(DEVICE_ID_KEY)) ?? ""; if (!_deviceId) { _deviceId = gen(); - await AsyncStorage.setItem(DEVICE_ID_KEY, _deviceId); + await SessionStorage.setItem(DEVICE_ID_KEY, _deviceId); } - _capsDoneServer = (await AsyncStorage.getItem(CAPS_DONE_KEY)) ?? ""; + _capsDoneServer = (await SessionStorage.getItem(CAPS_DONE_KEY)) ?? ""; } export function deviceId(): string { @@ -41,8 +41,9 @@ export function capsDoneForCurrentServer(): boolean { } export async function markCapsDone(): Promise { - _capsDoneServer = getServerUrl(); - await AsyncStorage.setItem(CAPS_DONE_KEY, _capsDoneServer); + const server = getServerUrl(); + await SessionStorage.setItem(CAPS_DONE_KEY, server); + _capsDoneServer = server; } /** The device report sent on sign-in. On native the codec facts come from the diagnostic's measured diff --git a/apps/tv-native/src/lib/session-route.ts b/apps/tv-native/src/lib/session-route.ts new file mode 100644 index 00000000..39ebedd4 --- /dev/null +++ b/apps/tv-native/src/lib/session-route.ts @@ -0,0 +1,6 @@ +import { getToken, hasServerUrl } from "./auth"; + +export function sessionRoute(): "/setup" | "/login" | "/guide" { + if (!hasServerUrl()) return "/setup"; + return getToken() ? "/guide" : "/login"; +} diff --git a/apps/tv-native/src/lib/session-storage.ts b/apps/tv-native/src/lib/session-storage.ts new file mode 100644 index 00000000..89d790ce --- /dev/null +++ b/apps/tv-native/src/lib/session-storage.ts @@ -0,0 +1,50 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { requireNativeModule } from "expo"; +import { Platform } from "react-native"; + +export const isAppleTV = Platform.OS === "ios" && Platform.isTV; + +type NativeSessionStore = { + getItem(key: string): string | null; + setItem(key: string, value: string): boolean; +}; + +// Lazy: other platforms never load this Apple-only module. Missing native support must fail +// visibly, rather than silently fall back to purgeable storage in an old development client. +function nativeStore(): NativeSessionStore { + return requireNativeModule("AirwaveSessionStore"); +} + +export const TVSessionStorage = { + getItem(key: string): string | null { + return nativeStore().getItem(key) || null; + }, + setItem(key: string, value: string): void { + if (!nativeStore().setItem(key, value)) throw new Error("Could not persist session storage"); + }, +}; + +/** Migrate surviving cache data once per key. Empty strings are tombstones: a logout/server + * change must never resurrect a legacy value, even if cleanup fails or caches later disappear. */ +async function read(key: string): Promise { + const stored = nativeStore().getItem(key); + if (stored !== null) return stored || null; + const legacy = await AsyncStorage.getItem(key); + TVSessionStorage.setItem(key, legacy ?? ""); + return legacy; +} + +export const SessionStorage = isAppleTV + ? { + getItem: read, + async setItem(key: string, value: string): Promise { + TVSessionStorage.setItem(key, value); + }, + async removeItem(key: string): Promise { + // Clean up the legacy credential before committing the durable deletion. If cleanup + // fails, the caller can retry without memory disagreeing with the durable store. + await AsyncStorage.removeItem(key); + TVSessionStorage.setItem(key, ""); + }, + } + : AsyncStorage; diff --git a/apps/tv-native/src/lib/startup.ts b/apps/tv-native/src/lib/startup.ts new file mode 100644 index 00000000..cc0241d9 --- /dev/null +++ b/apps/tv-native/src/lib/startup.ts @@ -0,0 +1,11 @@ +import { loadSession } from "./auth"; + +/** Wait for every hydration task to settle before retrying. Promise.all's early rejection + * otherwise lets a retry overlap still-running hydration and overwrite newer state. */ +export async function initializeApp(otherHydrations: Array<() => Promise>): Promise { + const results = await Promise.allSettled([loadSession(), ...otherHydrations.map((load) => load())]); + if (results.some((result) => result.status === "rejected")) { + // Native/storage errors can contain sensitive values. Surface only a fixed message. + throw new Error("Could not read saved settings. Please retry."); + } +} From 79daca93b4277861eb33b631bdf1c9ffd6556fe1 Mon Sep 17 00:00:00 2001 From: SHRIVES93 <75397028+SHRIVES93@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:47:58 -0400 Subject: [PATCH 2/5] test(tvos): cover session persistence and storage recovery The persistence fix needs regressions that distinguish missing settings from failed hydration and verify saved state across fresh application processes. - Add 28 Bun regressions against production session, startup, routing, device, credential, and REST modules, substituting native storage/network boundaries. Cover migration/cache eviction, restart, invalid sessions, transient failures, read/write/cleanup errors, logout, Change Server, and concurrent/stale 401s. - Check the production UserDefaults implementation with a temporary Swift test suite: persist/reopen, deletion markers, allowlist/size/corruption rejection, and rollback when synchronization fails. - Add the native app test command, focused CI for regressions/typechecking and Swift checks, and instructions for device validation. Validation: session regressions and native store checks pass; the user confirms all physical Apple TV acceptance tests pass. --- .github/workflows/tv-native-session.yml | 45 ++++++ .../tv-native/modules/session-store/README.md | 18 +++ .../tests/PersistentSessionStoreTests.swift | 46 ++++++ apps/tv-native/package.json | 3 +- apps/tv-native/src/lib/session-test-worker.ts | 104 +++++++++++++ apps/tv-native/src/lib/session.test.ts | 140 ++++++++++++++++++ 6 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tv-native-session.yml create mode 100644 apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift create mode 100644 apps/tv-native/src/lib/session-test-worker.ts create mode 100644 apps/tv-native/src/lib/session.test.ts diff --git a/.github/workflows/tv-native-session.yml b/.github/workflows/tv-native-session.yml new file mode 100644 index 00000000..63537c94 --- /dev/null +++ b/.github/workflows/tv-native-session.yml @@ -0,0 +1,45 @@ +name: Native TV session regression checks + +on: + pull_request: + paths: + - "apps/tv-native/**" + - "packages/key-input/**" + - "packages/mpv-player/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/tv-native-session.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + session: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + - run: pnpm install --frozen-lockfile --ignore-scripts --filter tv-native... + - run: pnpm --filter tv-native test + - run: pnpm --filter tv-native check-types + + user-defaults: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Compile and run the production UserDefaults store checks + run: | + swiftc apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift \ + apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift \ + -o "$RUNNER_TEMP/airwave-session-tests" + "$RUNNER_TEMP/airwave-session-tests" diff --git a/apps/tv-native/modules/session-store/README.md b/apps/tv-native/modules/session-store/README.md index 17e71d5d..64059e3e 100644 --- a/apps/tv-native/modules/session-store/README.md +++ b/apps/tv-native/modules/session-store/README.md @@ -27,3 +27,21 @@ Each value is restricted to 32 KiB and keys are allowlisted, keeping these six values below tvOS's 512 KiB UserDefaults warning threshold. The pod bundles the required UserDefaults privacy declaration. A new native binary is required; an OTA JavaScript update alone cannot supply this module. + +From the repository root: + +```sh +bun test apps/tv-native/src/lib/session.test.ts +pnpm --filter tv-native check-types +swiftc apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift \ + apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift \ + -o /tmp/airwave-session-tests +/tmp/airwave-session-tests +``` + +The Bun suite restarts actual session/startup/routing/API modules in separate +processes, substituting only native storage and network boundaries. The Swift +checks exercise the production UserDefaults implementation with a temporary +test suite. These checks do not replace tvOS release-build validation: verify +force-quit/relaunch, device restart, upgrade migration, expired sessions, +offline launch, logout, Change Server, and remote-driven Retry on Apple TV. diff --git a/apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift b/apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift new file mode 100644 index 00000000..f4fde556 --- /dev/null +++ b/apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift @@ -0,0 +1,46 @@ +import Foundation + +final class FailingDefaults: UserDefaults { + override func synchronize() -> Bool { false } +} + +@main +struct SessionStoreTests { + static func check(_ actual: String?, _ expected: String?) { assert(actual == expected) } + + static func main() throws { + let suite = "airwave-session-test-" + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = PersistentSessionStore(defaults: defaults) + check(try store.getItem("cg-tv-server-url"), nil) + try store.setItem("cg-tv-server-url", value: "https://example.test") + let restarted = PersistentSessionStore(defaults: UserDefaults(suiteName: suite)!) + check(try restarted.getItem("cg-tv-server-url"), "https://example.test") + try restarted.setItem("cg-tv-token", value: "test-credential") + check(try store.getItem("cg-tv-token"), "test-credential") + try restarted.setItem("cg-tv-token", value: "") + check(try store.getItem("cg-tv-token"), "") + do { + try store.setItem("unrelated-key", value: "x") + fatalError("Unsupported key accepted") + } catch { assert((error as NSError).code == 1) } + do { + try store.setItem("cg_cookie", value: String(repeating: "x", count: 32769)) + fatalError("Oversized value accepted") + } catch { assert((error as NSError).code == 3) } + defaults.set(123, forKey: "airwave.session.cg_cookie") + do { + _ = try store.getItem("cg_cookie") + fatalError("Corrupt value accepted") + } catch { assert((error as NSError).code == 2) } + let failingDefaults = FailingDefaults(suiteName: suite)! + let failing = PersistentSessionStore(defaults: failingDefaults) + do { + try failing.setItem("cg-tv-server-url", value: "https://other.test") + fatalError("Failed persistence accepted") + } catch { assert((error as NSError).code == 4) } + check(try failing.getItem("cg-tv-server-url"), "https://example.test") + print("PersistentSessionStore: all checks passed") + } +} diff --git a/apps/tv-native/package.json b/apps/tv-native/package.json index 81fdafc3..506686aa 100644 --- a/apps/tv-native/package.json +++ b/apps/tv-native/package.json @@ -10,7 +10,8 @@ "dev:ios": "expo start --dev-client --ios", "start": "expo start --dev-client", "prebuild": "expo prebuild", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "bun test src/lib/session.test.ts" }, "dependencies": { "@airwave/key-input": "workspace:*", diff --git a/apps/tv-native/src/lib/session-test-worker.ts b/apps/tv-native/src/lib/session-test-worker.ts new file mode 100644 index 00000000..36973370 --- /dev/null +++ b/apps/tv-native/src/lib/session-test-worker.ts @@ -0,0 +1,104 @@ +// A fresh Bun process per phase models JS module state being discarded on app termination. +// Only OS storage, native module and network boundaries are substituted; the session, +// startup, routing, credential and REST modules under test are the production modules. +import { mock } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; + +const [file, operation, rawOptions] = process.argv.slice(2); +const options = JSON.parse(rawOptions ?? "{}"); +const read = () => JSON.parse(readFileSync(file!, "utf8")); +function write(store: string, key: string, value: string | null) { + if (options.writeFailure) throw new Error("storage write failed"); + const data = read(); + if (value === null) delete data[store][key]; + else data[store][key] = value; + writeFileSync(file!, JSON.stringify(data)); +} +function get(store: string, key: string) { + if (options.readFailure || options.failKey === key) throw new Error("storage read failed"); + return read()[store][key] ?? null; +} +const asyncStore = { + getItem: async (key: string) => get("cache", key), + setItem: async (key: string, value: string) => write("cache", key, value), + removeItem: async (key: string) => { + if (options.cleanupFailure) throw new Error("legacy cleanup failed"); + write("cache", key, null); + }, +}; +mock.module("@react-native-async-storage/async-storage", () => ({ default: asyncStore })); +mock.module("react-native", () => ({ Platform: { + OS: options.platform === "android" ? "android" : "ios", + isTV: options.platform !== "ios", +}, Dimensions: { get: () => ({ width: 1920, height: 1080 }) }, PixelRatio: { get: () => 1 } })); +mock.module("@airwave/mpv-player", () => ({ mpvDisplay: { getInfo: () => null } })); +mock.module("expo", () => ({ requireNativeModule: () => { + if (options.missingNative) throw new Error("missing native module"); + return { + getItem: (key: string) => get("defaults", key), + setItem: (key: string, value: string) => { write("defaults", key, value); return true; }, + }; +} })); +mock.module("expo-secure-store", () => ({ + getItemAsync: async (key: string) => get("secure", key), + setItemAsync: async (key: string, value: string) => write("secure", key, value), + deleteItemAsync: async (key: string) => write("secure", key, null), + getItem: (key: string) => get("secure", key), + setItem: (key: string, value: string) => write("secure", key, value), +})); +let redirect: string | null = null; +mock.module("expo-router", () => ({ router: { replace: (route: string) => { redirect = route; } } })); +mock.module("./plex-connection", () => ({ getNetwork: () => "local" })); +process.env.EXPO_PUBLIC_SERVER_URL = ""; +const auth = await import("./auth"); +const { initializeApp } = await import("./startup"); +const { sessionRoute } = await import("./session-route"); +const { SyncCredStore } = await import("./cred-store"); +const { loadDevice, deviceId, markCapsDone, capsDoneForCurrentServer } = await import("./device"); +let error = false; +let ready = false; +let settled = false; +try { + await initializeApp([loadDevice, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + settled = true; + }]); + ready = true; + if (operation === "save") { + await auth.setServerUrl("example.test///"); + await auth.setToken("test-token"); + SyncCredStore.setItem("cg_cookie", "test-cookie"); + SyncCredStore.setItem("cg_session_data", "test-session"); + await markCapsDone(); + } else if (operation === "write-url") { + await auth.setServerUrl("different.test"); + } else if (operation === "write-token") { + await auth.setToken("different-token"); + } else if (operation === "logout") { + await auth.setToken(null); + } else if (operation === "change") { + await auth.setToken(null); + await auth.clearServerUrl(); + } else if (operation === "overlap") { + await Promise.all([auth.invalidateToken("test-token", auth.getServerUrl()), auth.setToken("new-test-token")]); + } else if (operation === "stale-invalidation") { + await Promise.all([auth.setToken("new-test-token"), auth.invalidateToken("test-token", auth.getServerUrl())]); + } else if (operation === "request") { + globalThis.fetch = (async () => { + if (options.networkFailure) throw new TypeError("offline"); + if (options.newToken) await auth.setToken("new-test-token"); + return new Response(JSON.stringify({ packages: [] }), { status: options.status ?? 200 }); + }) as unknown as typeof fetch; + const { api } = await import("./api"); + await api.packages(); + } +} catch { error = true; } +console.log(JSON.stringify({ + ready, error, settled, redirect, + route: ready ? sessionRoute() : null, + server: auth.getServerUrl(), + hasToken: !!auth.getToken(), + cookie: ready ? SyncCredStore.getItem("cg_cookie") : null, + device: ready ? deviceId() : null, + capsDone: ready ? capsDoneForCurrentServer() : false, +})); diff --git a/apps/tv-native/src/lib/session.test.ts b/apps/tv-native/src/lib/session.test.ts new file mode 100644 index 00000000..eb6b4ecc --- /dev/null +++ b/apps/tv-native/src/lib/session.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true }); }); + +function fixture(cache: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), "airwave-session-")); + dirs.push(dir); + const file = join(dir, "storage.json"); + writeFileSync(file, JSON.stringify({ cache, defaults: {}, secure: {} })); + return { + run(operation = "launch", options = {}) { + const process = Bun.spawnSync([Bun.which("bun")!, join(import.meta.dir, "session-test-worker.ts"), + file, operation, JSON.stringify(options)]); + if (process.exitCode !== 0) throw new Error(process.stderr.toString()); + return JSON.parse(process.stdout.toString()); + }, + data: () => JSON.parse(readFileSync(file, "utf8")), + purgeCache() { + const data = JSON.parse(readFileSync(file, "utf8")); + data.cache = {}; + writeFileSync(file, JSON.stringify(data)); + }, + }; +} + +describe("native session persistence across fresh application processes", () => { + test("first launch routes to setup", () => { + expect(fixture().run()).toMatchObject({ ready: true, error: false, route: "/setup" }); + }); + test("server and all credentials survive a restart and tvOS cache eviction", () => { + const app = fixture(); + expect(app.run("save")).toMatchObject({ error: false, server: "https://example.test", route: "/guide" }); + app.purgeCache(); + expect(app.run()).toMatchObject({ error: false, server: "https://example.test", hasToken: true, + cookie: "test-cookie", route: "/guide", capsDone: true }); + }); + test("migrates surviving legacy URL, bearer and Expo credentials", () => { + const app = fixture({ "cg-tv-server-url": "https://legacy.test/", "cg-tv-token": "legacy-test-token", + cg_cookie: "legacy-cookie", cg_session_data: "legacy-session", "unrelated-pref": "stay-in-cache" }); + expect(app.run()).toMatchObject({ route: "/guide", server: "https://legacy.test", cookie: "legacy-cookie" }); + expect(app.data().defaults["unrelated-pref"]).toBeUndefined(); + app.purgeCache(); + expect(app.run()).toMatchObject({ route: "/guide", cookie: "legacy-cookie" }); + }); + test("saved server without token routes to login", () => { + expect(fixture({ "cg-tv-server-url": "https://example.test" }).run()).toMatchObject({ route: "/login" }); + }); + test("valid server response preserves authentication", () => { + const app = fixture(); app.run("save"); + expect(app.run("request")).toMatchObject({ error: false, route: "/guide", redirect: null }); + }); + test("401 clears authentication but preserves the server across restarts", () => { + const app = fixture(); app.run("save"); + expect(app.run("request", { status: 401 })).toMatchObject({ error: true, redirect: "/login", + hasToken: false, server: "https://example.test", cookie: null }); + expect(app.run()).toMatchObject({ route: "/login", server: "https://example.test" }); + }); + test("late 401 does not invalidate a newer login", () => { + const app = fixture(); app.run("save"); + expect(app.run("request", { status: 401, newToken: true })).toMatchObject({ hasToken: true, redirect: null }); + }); + for (const operation of ["overlap", "stale-invalidation"]) { + test(`401 invalidation cannot erase a concurrent login: ${operation}`, () => { + const app = fixture(); app.run("save"); + expect(app.run(operation)).toMatchObject({ error: false, hasToken: true, route: "/guide" }); + expect(app.data().defaults["cg-tv-token"]).toBe("new-test-token"); + }); + } + test("device identity and completed capability onboarding survive cache eviction", () => { + const app = fixture(); + const saved = app.run("save"); + app.purgeCache(); + expect(app.run()).toMatchObject({ device: saved.device, capsDone: true }); + }); + test("legacy cleanup failure does not commit a deletion or report success", () => { + const app = fixture(); app.run("save"); + expect(app.run("logout", { cleanupFailure: true })).toMatchObject({ error: true, hasToken: true }); + expect(app.run()).toMatchObject({ route: "/guide", cookie: "test-cookie" }); + expect(app.run("logout")).toMatchObject({ error: false, route: "/login" }); + }); + for (const options of [{ networkFailure: true }, { status: 503 }, { status: 403 }]) { + test(`temporary/permission failure preserves session: ${JSON.stringify(options)}`, () => { + const app = fixture(); app.run("save"); + expect(app.run("request", options)).toMatchObject({ error: true, hasToken: true, + server: "https://example.test", route: "/guide", redirect: null }); + }); + } + for (const options of [{ readFailure: true }, { failKey: "cg-tv-token" }, + { failKey: "cg_cookie" }, { missingNative: true }]) { + test(`startup failures prevent routing and can be retried: ${JSON.stringify(options)}`, () => { + const app = fixture(); app.run("save"); + expect(app.run("launch", options)).toMatchObject({ ready: false, error: true, route: null, settled: true }); + expect(app.run()).toMatchObject({ error: false, route: "/guide" }); + }); + } + test("legacy read failure does not create a migration tombstone", () => { + const app = fixture({ "cg-tv-server-url": "https://example.test" }); + expect(app.run("launch", { readFailure: true })).toMatchObject({ ready: false }); + expect(app.data().defaults).toEqual({}); + expect(app.run()).toMatchObject({ route: "/login" }); + }); + test("migration write failure is surfaced and recoverable", () => { + const app = fixture({ "cg-tv-server-url": "https://example.test" }); + expect(app.run("launch", { writeFailure: true })).toMatchObject({ ready: false, error: true }); + expect(app.run()).toMatchObject({ server: "https://example.test", route: "/login" }); + }); + for (const operation of ["write-url", "write-token", "logout", "change"]) { + test(`failed ${operation} does not report success or change memory`, () => { + const app = fixture(); app.run("save"); + expect(app.run(operation, { writeFailure: true })).toMatchObject({ error: true, + server: "https://example.test", hasToken: true }); + expect(app.run()).toMatchObject({ route: "/guide" }); + }); + } + test("explicit logout clears both credential interfaces and retains server", () => { + const app = fixture(); app.run("save"); + expect(app.run("logout")).toMatchObject({ error: false, route: "/login", cookie: null }); + expect(app.run()).toMatchObject({ route: "/login", server: "https://example.test" }); + }); + test("Change Server clears credentials and URL without legacy resurrection", () => { + const app = fixture({ "cg-tv-server-url": "https://old.test", "cg-tv-token": "old-test-token" }); + app.run("save"); + expect(app.run("change")).toMatchObject({ error: false, route: "/setup", cookie: null }); + expect(app.run()).toMatchObject({ route: "/setup", hasToken: false }); + }); + for (const platform of ["ios", "android"]) { + test(`${platform} keeps AsyncStorage URL and SecureStore credentials`, () => { + const app = fixture(); + expect(app.run("save", { platform, missingNative: true })).toMatchObject({ error: false, route: "/guide" }); + expect(app.data().defaults).toEqual({}); + expect(app.data().cache["cg-tv-server-url"]).toBe("https://example.test"); + expect(app.data().secure["cg-tv-token"]).toBe("test-token"); + expect(app.run("launch", { platform })).toMatchObject({ route: "/guide" }); + }); + } +}); From b3a104f1f2d0569723afbe66ec534ac0c574a71e Mon Sep 17 00:00:00 2001 From: quixomatic Date: Tue, 13 Oct 2026 13:14:47 -0400 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20v0.15.66=20=E2=80=94=20Apple=20TV=20?= =?UTF-8?q?session=20persists=20in=20UserDefaults=20(issue=20#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On tvOS, AsyncStorage lives in a purgeable cache, so the server URL + session were wiped on reboot and the TV fell back to onboarding. Back those small values with core RN Settings (NSUserDefaults), the one durable tvOS store, gated to Apple TV (Platform.OS==="ios" && isTV); empty-string tombstones handle deletes; a one-time migration carries over surviving AsyncStorage values. iPad/iPhone/Android unchanged. Strips the bespoke native Swift module, its CI workflow, and the multi-process test harness in favor of the built-in Settings module. Claude-Session: https://claude.ai/code/session_0141ntBdutphMZLw7DW5jUYJ --- .github/workflows/tv-native-session.yml | 45 ------ CHANGELOG.md | 10 ++ apps/desktop-setup/package.json | 2 +- apps/desktop/package.json | 2 +- apps/mcp/package.json | 2 +- apps/server/package.json | 2 +- apps/site/package.json | 2 +- apps/tv-native/.gitignore | 2 +- apps/tv-native/app.json | 2 +- .../tv-native/modules/session-store/README.md | 47 ------ .../session-store/expo-module.config.json | 4 - .../ios/PersistentSessionStore.swift | 45 ------ .../session-store/ios/PrivacyInfo.xcprivacy | 8 - .../session-store/ios/SessionStore.podspec | 13 -- .../ios/SessionStoreModule.swift | 16 -- .../modules/session-store/package.json | 5 - .../tests/PersistentSessionStoreTests.swift | 46 ------ apps/tv-native/package.json | 10 +- apps/tv-native/src/lib/session-storage.ts | 56 ++++--- apps/tv-native/src/lib/session-test-worker.ts | 104 ------------- apps/tv-native/src/lib/session.test.ts | 140 ------------------ apps/tv-roku/manifest | 2 +- apps/tv-roku/package.json | 2 +- apps/tv-tauri/package.json | 2 +- apps/tv-tauri/src-tauri/Cargo.lock | 2 +- apps/tv-tauri/src-tauri/Cargo.toml | 2 +- apps/tv-tauri/src-tauri/tauri.conf.json | 2 +- apps/tv-web/package.json | 2 +- apps/tv-web/public/appinfo.json | 2 +- apps/tv-web/public/config.xml | 2 +- apps/web/package.json | 2 +- scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- 33 files changed, 66 insertions(+), 521 deletions(-) delete mode 100644 .github/workflows/tv-native-session.yml delete mode 100644 apps/tv-native/modules/session-store/README.md delete mode 100644 apps/tv-native/modules/session-store/expo-module.config.json delete mode 100644 apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift delete mode 100644 apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy delete mode 100644 apps/tv-native/modules/session-store/ios/SessionStore.podspec delete mode 100644 apps/tv-native/modules/session-store/ios/SessionStoreModule.swift delete mode 100644 apps/tv-native/modules/session-store/package.json delete mode 100644 apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift delete mode 100644 apps/tv-native/src/lib/session-test-worker.ts delete mode 100644 apps/tv-native/src/lib/session.test.ts diff --git a/.github/workflows/tv-native-session.yml b/.github/workflows/tv-native-session.yml deleted file mode 100644 index 63537c94..00000000 --- a/.github/workflows/tv-native-session.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Native TV session regression checks - -on: - pull_request: - paths: - - "apps/tv-native/**" - - "packages/key-input/**" - - "packages/mpv-player/**" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - ".github/workflows/tv-native-session.yml" - workflow_dispatch: - -permissions: - contents: read - -jobs: - session: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.11.0 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.2 - - run: pnpm install --frozen-lockfile --ignore-scripts --filter tv-native... - - run: pnpm --filter tv-native test - - run: pnpm --filter tv-native check-types - - user-defaults: - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - name: Compile and run the production UserDefaults store checks - run: | - swiftc apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift \ - apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift \ - -o "$RUNNER_TEMP/airwave-session-tests" - "$RUNNER_TEMP/airwave-session-tests" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0376430d..ebd5756b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Airwave are documented here. +## [0.15.66] - 2026-10-13 + +Fixes the Apple TV dropping back to server setup after a restart (issue #64). + +### Fixed +- **Apple TV now keeps its server and session across a reboot.** On tvOS, the server URL, token, and session were stored in AsyncStorage, which the OS keeps in a purgeable cache and clears when storage runs low — so after a restart the Apple TV had forgotten its server and returned to onboarding. Those small values now live in UserDefaults (via React Native's built-in `Settings`), the one tvOS store with a durability guarantee, so they survive a reboot. No new native module or dependency; iPad, iPhone, and Android are unchanged (they keep the Keychain/Keystore and AsyncStorage). A one-time migration carries over any values that haven't been purged yet; an Apple TV whose cache was already cleared re-onboards once. + +### Internal +- `session-storage.ts` backs the Apple-TV durable store with core RN `Settings` (NSUserDefaults), gated on `Platform.OS === "ios" && Platform.isTV`, using an empty-string tombstone for deletes (UserDefaults has no remove). + ## [0.15.65] - 2026-10-12 ### Fixed diff --git a/apps/desktop-setup/package.json b/apps/desktop-setup/package.json index ffdaa7e9..1db8cf9b 100644 --- a/apps/desktop-setup/package.json +++ b/apps/desktop-setup/package.json @@ -1,6 +1,6 @@ { "name": "@airwave/desktop-setup", - "version": "0.15.65", + "version": "0.15.66", "private": true, "type": "module", "description": "Airwave Desktop first-run onboarding / settings UI — a small Vite + React app built with @airwave/ui (shadcn + oklch theme), rendered in the desktop app's native webview and driven by the supervisor's /config, /save, /status endpoints.", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 38824544..24e7a715 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.65", + "version": "0.15.66", "private": true, "type": "module", "description": "Airwave Desktop — an Electrobun tray-only supervisor that runs embedded Postgres + the Airwave server + admin + tv-web on local ports, next to Plex. The browser is the UI. See .plans/desktop-server.md.", diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 6d4e3ba3..746dc880 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@airwave/mcp", - "version": "0.15.65", + "version": "0.15.66", "private": true, "type": "module", "description": "MCP server exposing a running Airwave server's tools to any MCP-capable agent.", diff --git a/apps/server/package.json b/apps/server/package.json index e33e59f6..0c319f4b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.15.65", + "version": "0.15.66", "type": "module", "main": "src/index.ts", "scripts": { diff --git a/apps/site/package.json b/apps/site/package.json index a949ddf8..56d60c11 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "site", - "version": "0.15.65", + "version": "0.15.66", "private": true, "type": "module", "scripts": { diff --git a/apps/tv-native/.gitignore b/apps/tv-native/.gitignore index 477e76f4..a0103d39 100644 --- a/apps/tv-native/.gitignore +++ b/apps/tv-native/.gitignore @@ -3,7 +3,7 @@ dist/ web-build/ android/ -/ios/ +ios/ *.orig.* # Expo Router / TypeScript generated diff --git a/apps/tv-native/app.json b/apps/tv-native/app.json index fe1ae64d..21c93a09 100644 --- a/apps/tv-native/app.json +++ b/apps/tv-native/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Airwave", "slug": "airwave", - "version": "0.15.65", + "version": "0.15.66", "scheme": "airwave", "orientation": "landscape", "userInterfaceStyle": "dark", diff --git a/apps/tv-native/modules/session-store/README.md b/apps/tv-native/modules/session-store/README.md deleted file mode 100644 index 64059e3e..00000000 --- a/apps/tv-native/modules/session-store/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Apple TV session storage - -AsyncStorage 2.2.0 uses `NSCachesDirectory` on tvOS and warns that its contents may -be removed. This local Expo module stores six small values in the app's -UserDefaults instead: server URL, bearer token, the two Better Auth Expo values, -device ID, and the server-specific capability onboarding flag. - -The JS adapter migrates surviving AsyncStorage values before routing. An empty -string marks a migrated missing/deleted value, preventing old cache data from -restoring a logged-out session or a previous server. Already-purged values cannot -be recovered: affected users must configure/login once after upgrading. - -Synchronous Expo functions let both credential interfaces read the same store. -Writes acknowledge UserDefaults synchronization before setup/login navigates. -Read/write errors reach the caller; startup provides a remote/touch Retry action -and never interprets a read error as a new installation. Logout clears bearer, -cookie and cached session values while retaining the server. Change Server also -clears the URL. Only a 401 for the current bearer session requires login; -network, 403 and 5xx failures retain configuration and credentials. - -This preserves the existing keychain-free tvOS workaround. UserDefaults is not -encrypted storage, just as the former AsyncStorage fallback was not encrypted. -iOS/iPadOS and Android retain SecureStore for credentials and AsyncStorage for -configuration. No session expiry or server authorization rules change. - -Each value is restricted to 32 KiB and keys are allowlisted, keeping these six -values below tvOS's 512 KiB UserDefaults warning threshold. The pod bundles the -required UserDefaults privacy declaration. A new native binary is required; -an OTA JavaScript update alone cannot supply this module. - -From the repository root: - -```sh -bun test apps/tv-native/src/lib/session.test.ts -pnpm --filter tv-native check-types -swiftc apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift \ - apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift \ - -o /tmp/airwave-session-tests -/tmp/airwave-session-tests -``` - -The Bun suite restarts actual session/startup/routing/API modules in separate -processes, substituting only native storage and network boundaries. The Swift -checks exercise the production UserDefaults implementation with a temporary -test suite. These checks do not replace tvOS release-build validation: verify -force-quit/relaunch, device restart, upgrade migration, expired sessions, -offline launch, logout, Change Server, and remote-driven Retry on Apple TV. diff --git a/apps/tv-native/modules/session-store/expo-module.config.json b/apps/tv-native/modules/session-store/expo-module.config.json deleted file mode 100644 index daea4db7..00000000 --- a/apps/tv-native/modules/session-store/expo-module.config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "platforms": ["apple"], - "apple": { "modules": ["SessionStoreModule"] } -} diff --git a/apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift b/apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift deleted file mode 100644 index 7b637366..00000000 --- a/apps/tv-native/modules/session-store/ios/PersistentSessionStore.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -/// AsyncStorage uses purgeable caches on tvOS. Store only these small configuration/session -/// values in UserDefaults, without the keychain used by SecureStore on other platforms. -final class PersistentSessionStore { - private let defaults: UserDefaults - private let prefix = "airwave.session." - private let keys: Set = [ - "cg-tv-server-url", "cg-tv-token", "cg_cookie", "cg_session_data", - "cg-device-id", "cg-caps-done" - ] - - init(defaults: UserDefaults = .standard) { self.defaults = defaults } - - private func storageKey(_ key: String) throws -> String { - guard keys.contains(key) else { throw failure(1, "Unsupported session storage key") } - return prefix + key - } - - private func failure(_ code: Int, _ message: String) -> NSError { - NSError(domain: "SessionStore", code: code, userInfo: [NSLocalizedDescriptionKey: message]) - } - - func getItem(_ key: String) throws -> String? { - let value = defaults.object(forKey: try storageKey(key)) - guard let value else { return nil } - guard let string = value as? String else { throw failure(2, "Invalid session storage value") } - return string - } - - func setItem(_ key: String, value: String) throws { - // Six bounded strings stay below tvOS's 512 KB UserDefaults warning threshold. - guard value.utf8.count <= 32 * 1024 else { throw failure(3, "Session storage value is too large") } - let key = try storageKey(key) - let previous = defaults.object(forKey: key) - defaults.set(value, forKey: key) - // A synchronous acknowledgement lets setup/login await persistence before navigating. - // This is intentionally stronger than an asynchronous UserDefaults/Settings write. - guard defaults.synchronize() else { - if let previous { defaults.set(previous, forKey: key) } - else { defaults.removeObject(forKey: key) } - throw failure(4, "Could not persist session storage") - } - } -} diff --git a/apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy b/apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy deleted file mode 100644 index 7105f70e..00000000 --- a/apps/tv-native/modules/session-store/ios/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,8 +0,0 @@ - - - - NSPrivacyAccessedAPITypes - NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasonsCA92.1 - - diff --git a/apps/tv-native/modules/session-store/ios/SessionStore.podspec b/apps/tv-native/modules/session-store/ios/SessionStore.podspec deleted file mode 100644 index 25ef7ed2..00000000 --- a/apps/tv-native/modules/session-store/ios/SessionStore.podspec +++ /dev/null @@ -1,13 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'SessionStore' - s.version = '0.0.0' - s.summary = 'Keychain-free persistent session storage for Apple TV.' - s.author = 'Airwave' - s.homepage = 'https://github.com/Quixomatic/Airwave' - s.platforms = { :ios => '15.1', :tvos => '15.1' } - s.source = { git: '' } - s.dependency 'ExpoModulesCore' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.source_files = '**/*.swift' - s.resource_bundles = { 'SessionStore_privacy' => ['PrivacyInfo.xcprivacy'] } -end diff --git a/apps/tv-native/modules/session-store/ios/SessionStoreModule.swift b/apps/tv-native/modules/session-store/ios/SessionStoreModule.swift deleted file mode 100644 index 57c9075b..00000000 --- a/apps/tv-native/modules/session-store/ios/SessionStoreModule.swift +++ /dev/null @@ -1,16 +0,0 @@ -import ExpoModulesCore - -public class SessionStoreModule: Module { - private let store = PersistentSessionStore() - - public func definition() -> ModuleDefinition { - Name("AirwaveSessionStore") - Function("getItem") { (key: String) throws -> String? in - try self.store.getItem(key) - } - Function("setItem") { (key: String, value: String) throws -> Bool in - try self.store.setItem(key, value: value) - return true - } - } -} diff --git a/apps/tv-native/modules/session-store/package.json b/apps/tv-native/modules/session-store/package.json deleted file mode 100644 index f72ae0d3..00000000 --- a/apps/tv-native/modules/session-store/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "airwave-session-store", - "version": "0.0.0", - "private": true -} diff --git a/apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift b/apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift deleted file mode 100644 index f4fde556..00000000 --- a/apps/tv-native/modules/session-store/tests/PersistentSessionStoreTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -final class FailingDefaults: UserDefaults { - override func synchronize() -> Bool { false } -} - -@main -struct SessionStoreTests { - static func check(_ actual: String?, _ expected: String?) { assert(actual == expected) } - - static func main() throws { - let suite = "airwave-session-test-" + UUID().uuidString - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - let store = PersistentSessionStore(defaults: defaults) - check(try store.getItem("cg-tv-server-url"), nil) - try store.setItem("cg-tv-server-url", value: "https://example.test") - let restarted = PersistentSessionStore(defaults: UserDefaults(suiteName: suite)!) - check(try restarted.getItem("cg-tv-server-url"), "https://example.test") - try restarted.setItem("cg-tv-token", value: "test-credential") - check(try store.getItem("cg-tv-token"), "test-credential") - try restarted.setItem("cg-tv-token", value: "") - check(try store.getItem("cg-tv-token"), "") - do { - try store.setItem("unrelated-key", value: "x") - fatalError("Unsupported key accepted") - } catch { assert((error as NSError).code == 1) } - do { - try store.setItem("cg_cookie", value: String(repeating: "x", count: 32769)) - fatalError("Oversized value accepted") - } catch { assert((error as NSError).code == 3) } - defaults.set(123, forKey: "airwave.session.cg_cookie") - do { - _ = try store.getItem("cg_cookie") - fatalError("Corrupt value accepted") - } catch { assert((error as NSError).code == 2) } - let failingDefaults = FailingDefaults(suiteName: suite)! - let failing = PersistentSessionStore(defaults: failingDefaults) - do { - try failing.setItem("cg-tv-server-url", value: "https://other.test") - fatalError("Failed persistence accepted") - } catch { assert((error as NSError).code == 4) } - check(try failing.getItem("cg-tv-server-url"), "https://example.test") - print("PersistentSessionStore: all checks passed") - } -} diff --git a/apps/tv-native/package.json b/apps/tv-native/package.json index d0fb5932..c029699b 100644 --- a/apps/tv-native/package.json +++ b/apps/tv-native/package.json @@ -1,6 +1,6 @@ { "name": "tv-native", - "version": "0.15.65", + "version": "0.15.66", "private": true, "main": "expo-router/entry", "scripts": { @@ -10,8 +10,7 @@ "dev:ios": "expo start --dev-client --ios", "start": "expo start --dev-client", "prebuild": "expo prebuild", - "check-types": "tsc --noEmit", - "test": "bun test src/lib/session.test.ts" + "check-types": "tsc --noEmit" }, "dependencies": { "@airwave/key-input": "workspace:*", @@ -53,10 +52,5 @@ "devDependencies": { "@react-native-tvos/config-tv": "^0.1.6", "tailwindcss": "^3.4.19" - }, - "expo": { - "autolinking": { - "nativeModulesDir": "./modules" - } } } diff --git a/apps/tv-native/src/lib/session-storage.ts b/apps/tv-native/src/lib/session-storage.ts index 89d790ce..d3e46433 100644 --- a/apps/tv-native/src/lib/session-storage.ts +++ b/apps/tv-native/src/lib/session-storage.ts @@ -1,36 +1,50 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { requireNativeModule } from "expo"; -import { Platform } from "react-native"; +import { Platform, Settings } from "react-native"; export const isAppleTV = Platform.OS === "ios" && Platform.isTV; -type NativeSessionStore = { - getItem(key: string): string | null; - setItem(key: string, value: string): boolean; -}; - -// Lazy: other platforms never load this Apple-only module. Missing native support must fail -// visibly, rather than silently fall back to purgeable storage in an old development client. -function nativeStore(): NativeSessionStore { - return requireNativeModule("AirwaveSessionStore"); -} +/** + * Durable key-value storage for the small session/config values, Apple-TV-aware. + * + * ## Why Apple TV is special + * On tvOS, `AsyncStorage` writes to `NSCachesDirectory`, which the OS PURGES when storage runs low after the + * app exits — so the server URL + session silently vanish and the TV drops back to onboarding after a reboot. + * The one tvOS store with a durability guarantee is **UserDefaults**, which React Native already exposes + * through its core **`Settings`** module (no native module, no extra dependency). So on Apple TV we back these + * values with `Settings`; every other platform keeps `AsyncStorage` unchanged. + * + * UserDefaults has no delete, so an **empty string is a tombstone**: `removeItem` writes `""`, and reads map + * `""` back to `null`. That also stops a stale (or not-yet-purged) legacy value from resurrecting a + * logged-out session or a previous server. + * + * ## Security note + * On Apple TV the bearer token already lived in (unencrypted) AsyncStorage, never the keychain — reading the + * keychain at startup crashed release builds (RN #54859) and tvOS keychain is unreliable across reboots + * anyway (see `cred-store.ts`). UserDefaults has the same confidentiality as that AsyncStorage fallback + * (plaintext inside the app sandbox) but is durable. iPad/iPhone/Android keep the real Keychain/Keystore via + * SecureStore for credentials. + */ +/** Synchronous NSUserDefaults access via RN's core `Settings`. Empty string reads back as absent (tombstone). */ export const TVSessionStorage = { getItem(key: string): string | null { - return nativeStore().getItem(key) || null; + const value = Settings.get(key); + return typeof value === "string" && value !== "" ? value : null; }, setItem(key: string, value: string): void { - if (!nativeStore().setItem(key, value)) throw new Error("Could not persist session storage"); + Settings.set({ [key]: value }); }, }; -/** Migrate surviving cache data once per key. Empty strings are tombstones: a logout/server - * change must never resurrect a legacy value, even if cleanup fails or caches later disappear. */ +/** + * Read a value, migrating a surviving pre-upgrade AsyncStorage value into UserDefaults once. A key that is + * already present in UserDefaults (including a `""` tombstone) is authoritative and never re-migrated. + */ async function read(key: string): Promise { - const stored = nativeStore().getItem(key); - if (stored !== null) return stored || null; + const stored = Settings.get(key); + if (stored != null) return stored !== "" ? (stored as string) : null; const legacy = await AsyncStorage.getItem(key); - TVSessionStorage.setItem(key, legacy ?? ""); + TVSessionStorage.setItem(key, legacy ?? ""); // tombstone when the legacy value is also gone return legacy; } @@ -41,8 +55,8 @@ export const SessionStorage = isAppleTV TVSessionStorage.setItem(key, value); }, async removeItem(key: string): Promise { - // Clean up the legacy credential before committing the durable deletion. If cleanup - // fails, the caller can retry without memory disagreeing with the durable store. + // Clear any surviving legacy copy first, then write the durable tombstone. If cleanup throws the + // caller can retry without the durable store disagreeing with what's in memory. await AsyncStorage.removeItem(key); TVSessionStorage.setItem(key, ""); }, diff --git a/apps/tv-native/src/lib/session-test-worker.ts b/apps/tv-native/src/lib/session-test-worker.ts deleted file mode 100644 index 36973370..00000000 --- a/apps/tv-native/src/lib/session-test-worker.ts +++ /dev/null @@ -1,104 +0,0 @@ -// A fresh Bun process per phase models JS module state being discarded on app termination. -// Only OS storage, native module and network boundaries are substituted; the session, -// startup, routing, credential and REST modules under test are the production modules. -import { mock } from "bun:test"; -import { readFileSync, writeFileSync } from "node:fs"; - -const [file, operation, rawOptions] = process.argv.slice(2); -const options = JSON.parse(rawOptions ?? "{}"); -const read = () => JSON.parse(readFileSync(file!, "utf8")); -function write(store: string, key: string, value: string | null) { - if (options.writeFailure) throw new Error("storage write failed"); - const data = read(); - if (value === null) delete data[store][key]; - else data[store][key] = value; - writeFileSync(file!, JSON.stringify(data)); -} -function get(store: string, key: string) { - if (options.readFailure || options.failKey === key) throw new Error("storage read failed"); - return read()[store][key] ?? null; -} -const asyncStore = { - getItem: async (key: string) => get("cache", key), - setItem: async (key: string, value: string) => write("cache", key, value), - removeItem: async (key: string) => { - if (options.cleanupFailure) throw new Error("legacy cleanup failed"); - write("cache", key, null); - }, -}; -mock.module("@react-native-async-storage/async-storage", () => ({ default: asyncStore })); -mock.module("react-native", () => ({ Platform: { - OS: options.platform === "android" ? "android" : "ios", - isTV: options.platform !== "ios", -}, Dimensions: { get: () => ({ width: 1920, height: 1080 }) }, PixelRatio: { get: () => 1 } })); -mock.module("@airwave/mpv-player", () => ({ mpvDisplay: { getInfo: () => null } })); -mock.module("expo", () => ({ requireNativeModule: () => { - if (options.missingNative) throw new Error("missing native module"); - return { - getItem: (key: string) => get("defaults", key), - setItem: (key: string, value: string) => { write("defaults", key, value); return true; }, - }; -} })); -mock.module("expo-secure-store", () => ({ - getItemAsync: async (key: string) => get("secure", key), - setItemAsync: async (key: string, value: string) => write("secure", key, value), - deleteItemAsync: async (key: string) => write("secure", key, null), - getItem: (key: string) => get("secure", key), - setItem: (key: string, value: string) => write("secure", key, value), -})); -let redirect: string | null = null; -mock.module("expo-router", () => ({ router: { replace: (route: string) => { redirect = route; } } })); -mock.module("./plex-connection", () => ({ getNetwork: () => "local" })); -process.env.EXPO_PUBLIC_SERVER_URL = ""; -const auth = await import("./auth"); -const { initializeApp } = await import("./startup"); -const { sessionRoute } = await import("./session-route"); -const { SyncCredStore } = await import("./cred-store"); -const { loadDevice, deviceId, markCapsDone, capsDoneForCurrentServer } = await import("./device"); -let error = false; -let ready = false; -let settled = false; -try { - await initializeApp([loadDevice, async () => { - await new Promise((resolve) => setTimeout(resolve, 5)); - settled = true; - }]); - ready = true; - if (operation === "save") { - await auth.setServerUrl("example.test///"); - await auth.setToken("test-token"); - SyncCredStore.setItem("cg_cookie", "test-cookie"); - SyncCredStore.setItem("cg_session_data", "test-session"); - await markCapsDone(); - } else if (operation === "write-url") { - await auth.setServerUrl("different.test"); - } else if (operation === "write-token") { - await auth.setToken("different-token"); - } else if (operation === "logout") { - await auth.setToken(null); - } else if (operation === "change") { - await auth.setToken(null); - await auth.clearServerUrl(); - } else if (operation === "overlap") { - await Promise.all([auth.invalidateToken("test-token", auth.getServerUrl()), auth.setToken("new-test-token")]); - } else if (operation === "stale-invalidation") { - await Promise.all([auth.setToken("new-test-token"), auth.invalidateToken("test-token", auth.getServerUrl())]); - } else if (operation === "request") { - globalThis.fetch = (async () => { - if (options.networkFailure) throw new TypeError("offline"); - if (options.newToken) await auth.setToken("new-test-token"); - return new Response(JSON.stringify({ packages: [] }), { status: options.status ?? 200 }); - }) as unknown as typeof fetch; - const { api } = await import("./api"); - await api.packages(); - } -} catch { error = true; } -console.log(JSON.stringify({ - ready, error, settled, redirect, - route: ready ? sessionRoute() : null, - server: auth.getServerUrl(), - hasToken: !!auth.getToken(), - cookie: ready ? SyncCredStore.getItem("cg_cookie") : null, - device: ready ? deviceId() : null, - capsDone: ready ? capsDoneForCurrentServer() : false, -})); diff --git a/apps/tv-native/src/lib/session.test.ts b/apps/tv-native/src/lib/session.test.ts deleted file mode 100644 index eb6b4ecc..00000000 --- a/apps/tv-native/src/lib/session.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const dirs: string[] = []; -afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true }); }); - -function fixture(cache: Record = {}) { - const dir = mkdtempSync(join(tmpdir(), "airwave-session-")); - dirs.push(dir); - const file = join(dir, "storage.json"); - writeFileSync(file, JSON.stringify({ cache, defaults: {}, secure: {} })); - return { - run(operation = "launch", options = {}) { - const process = Bun.spawnSync([Bun.which("bun")!, join(import.meta.dir, "session-test-worker.ts"), - file, operation, JSON.stringify(options)]); - if (process.exitCode !== 0) throw new Error(process.stderr.toString()); - return JSON.parse(process.stdout.toString()); - }, - data: () => JSON.parse(readFileSync(file, "utf8")), - purgeCache() { - const data = JSON.parse(readFileSync(file, "utf8")); - data.cache = {}; - writeFileSync(file, JSON.stringify(data)); - }, - }; -} - -describe("native session persistence across fresh application processes", () => { - test("first launch routes to setup", () => { - expect(fixture().run()).toMatchObject({ ready: true, error: false, route: "/setup" }); - }); - test("server and all credentials survive a restart and tvOS cache eviction", () => { - const app = fixture(); - expect(app.run("save")).toMatchObject({ error: false, server: "https://example.test", route: "/guide" }); - app.purgeCache(); - expect(app.run()).toMatchObject({ error: false, server: "https://example.test", hasToken: true, - cookie: "test-cookie", route: "/guide", capsDone: true }); - }); - test("migrates surviving legacy URL, bearer and Expo credentials", () => { - const app = fixture({ "cg-tv-server-url": "https://legacy.test/", "cg-tv-token": "legacy-test-token", - cg_cookie: "legacy-cookie", cg_session_data: "legacy-session", "unrelated-pref": "stay-in-cache" }); - expect(app.run()).toMatchObject({ route: "/guide", server: "https://legacy.test", cookie: "legacy-cookie" }); - expect(app.data().defaults["unrelated-pref"]).toBeUndefined(); - app.purgeCache(); - expect(app.run()).toMatchObject({ route: "/guide", cookie: "legacy-cookie" }); - }); - test("saved server without token routes to login", () => { - expect(fixture({ "cg-tv-server-url": "https://example.test" }).run()).toMatchObject({ route: "/login" }); - }); - test("valid server response preserves authentication", () => { - const app = fixture(); app.run("save"); - expect(app.run("request")).toMatchObject({ error: false, route: "/guide", redirect: null }); - }); - test("401 clears authentication but preserves the server across restarts", () => { - const app = fixture(); app.run("save"); - expect(app.run("request", { status: 401 })).toMatchObject({ error: true, redirect: "/login", - hasToken: false, server: "https://example.test", cookie: null }); - expect(app.run()).toMatchObject({ route: "/login", server: "https://example.test" }); - }); - test("late 401 does not invalidate a newer login", () => { - const app = fixture(); app.run("save"); - expect(app.run("request", { status: 401, newToken: true })).toMatchObject({ hasToken: true, redirect: null }); - }); - for (const operation of ["overlap", "stale-invalidation"]) { - test(`401 invalidation cannot erase a concurrent login: ${operation}`, () => { - const app = fixture(); app.run("save"); - expect(app.run(operation)).toMatchObject({ error: false, hasToken: true, route: "/guide" }); - expect(app.data().defaults["cg-tv-token"]).toBe("new-test-token"); - }); - } - test("device identity and completed capability onboarding survive cache eviction", () => { - const app = fixture(); - const saved = app.run("save"); - app.purgeCache(); - expect(app.run()).toMatchObject({ device: saved.device, capsDone: true }); - }); - test("legacy cleanup failure does not commit a deletion or report success", () => { - const app = fixture(); app.run("save"); - expect(app.run("logout", { cleanupFailure: true })).toMatchObject({ error: true, hasToken: true }); - expect(app.run()).toMatchObject({ route: "/guide", cookie: "test-cookie" }); - expect(app.run("logout")).toMatchObject({ error: false, route: "/login" }); - }); - for (const options of [{ networkFailure: true }, { status: 503 }, { status: 403 }]) { - test(`temporary/permission failure preserves session: ${JSON.stringify(options)}`, () => { - const app = fixture(); app.run("save"); - expect(app.run("request", options)).toMatchObject({ error: true, hasToken: true, - server: "https://example.test", route: "/guide", redirect: null }); - }); - } - for (const options of [{ readFailure: true }, { failKey: "cg-tv-token" }, - { failKey: "cg_cookie" }, { missingNative: true }]) { - test(`startup failures prevent routing and can be retried: ${JSON.stringify(options)}`, () => { - const app = fixture(); app.run("save"); - expect(app.run("launch", options)).toMatchObject({ ready: false, error: true, route: null, settled: true }); - expect(app.run()).toMatchObject({ error: false, route: "/guide" }); - }); - } - test("legacy read failure does not create a migration tombstone", () => { - const app = fixture({ "cg-tv-server-url": "https://example.test" }); - expect(app.run("launch", { readFailure: true })).toMatchObject({ ready: false }); - expect(app.data().defaults).toEqual({}); - expect(app.run()).toMatchObject({ route: "/login" }); - }); - test("migration write failure is surfaced and recoverable", () => { - const app = fixture({ "cg-tv-server-url": "https://example.test" }); - expect(app.run("launch", { writeFailure: true })).toMatchObject({ ready: false, error: true }); - expect(app.run()).toMatchObject({ server: "https://example.test", route: "/login" }); - }); - for (const operation of ["write-url", "write-token", "logout", "change"]) { - test(`failed ${operation} does not report success or change memory`, () => { - const app = fixture(); app.run("save"); - expect(app.run(operation, { writeFailure: true })).toMatchObject({ error: true, - server: "https://example.test", hasToken: true }); - expect(app.run()).toMatchObject({ route: "/guide" }); - }); - } - test("explicit logout clears both credential interfaces and retains server", () => { - const app = fixture(); app.run("save"); - expect(app.run("logout")).toMatchObject({ error: false, route: "/login", cookie: null }); - expect(app.run()).toMatchObject({ route: "/login", server: "https://example.test" }); - }); - test("Change Server clears credentials and URL without legacy resurrection", () => { - const app = fixture({ "cg-tv-server-url": "https://old.test", "cg-tv-token": "old-test-token" }); - app.run("save"); - expect(app.run("change")).toMatchObject({ error: false, route: "/setup", cookie: null }); - expect(app.run()).toMatchObject({ route: "/setup", hasToken: false }); - }); - for (const platform of ["ios", "android"]) { - test(`${platform} keeps AsyncStorage URL and SecureStore credentials`, () => { - const app = fixture(); - expect(app.run("save", { platform, missingNative: true })).toMatchObject({ error: false, route: "/guide" }); - expect(app.data().defaults).toEqual({}); - expect(app.data().cache["cg-tv-server-url"]).toBe("https://example.test"); - expect(app.data().secure["cg-tv-token"]).toBe("test-token"); - expect(app.run("launch", { platform })).toMatchObject({ route: "/guide" }); - }); - } -}); diff --git a/apps/tv-roku/manifest b/apps/tv-roku/manifest index 99d3dfd9..10bbe4f2 100644 --- a/apps/tv-roku/manifest +++ b/apps/tv-roku/manifest @@ -3,7 +3,7 @@ title=Airwave # Version — kept in lockstep with the other apps/* by scripts/bump-version.ts (see /version-bump). major_version=0 minor_version=15 -build_version=65 +build_version=66 # Icons = branded logo mark on the radial gradient (home-screen tile). Splash = a FLAT #060a14 fill (Roku # requires a splash image, but ours hands off seamlessly to the in-app animated LogoLockup, which fades the diff --git a/apps/tv-roku/package.json b/apps/tv-roku/package.json index 8745eb7c..8180108a 100644 --- a/apps/tv-roku/package.json +++ b/apps/tv-roku/package.json @@ -1,6 +1,6 @@ { "name": "tv-roku", - "version": "0.15.65", + "version": "0.15.66", "private": true, "description": "Airwave Roku client (BrighterScript + SceneGraph). A direct port of tv-web/tv-native to Roku — strict visual + functional parity, its own separately-maintained codebase.", "scripts": { diff --git a/apps/tv-tauri/package.json b/apps/tv-tauri/package.json index e0ed8188..c54e09fa 100644 --- a/apps/tv-tauri/package.json +++ b/apps/tv-tauri/package.json @@ -1,6 +1,6 @@ { "name": "tv-tauri", - "version": "0.15.65", + "version": "0.15.66", "private": true, "type": "module", "scripts": { diff --git a/apps/tv-tauri/src-tauri/Cargo.lock b/apps/tv-tauri/src-tauri/Cargo.lock index 9384eed7..a4fce3d1 100644 --- a/apps/tv-tauri/src-tauri/Cargo.lock +++ b/apps/tv-tauri/src-tauri/Cargo.lock @@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "airwave" -version = "0.15.65" +version = "0.15.66" dependencies = [ "futures", "gtk", diff --git a/apps/tv-tauri/src-tauri/Cargo.toml b/apps/tv-tauri/src-tauri/Cargo.toml index 99458bbe..94e4fa06 100644 --- a/apps/tv-tauri/src-tauri/Cargo.toml +++ b/apps/tv-tauri/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "airwave" -version = "0.15.65" +version = "0.15.66" description = "Airwave desktop client (Tauri + libmpv)" authors = ["Airwave"] license = "" diff --git a/apps/tv-tauri/src-tauri/tauri.conf.json b/apps/tv-tauri/src-tauri/tauri.conf.json index aa144a47..3bf95eb8 100644 --- a/apps/tv-tauri/src-tauri/tauri.conf.json +++ b/apps/tv-tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Airwave Client", - "version": "0.15.65", + "version": "0.15.66", "identifier": "com.airwave.tvdesktop", "build": { "frontendDist": "../dist", diff --git a/apps/tv-web/package.json b/apps/tv-web/package.json index 2590e575..4a10c740 100644 --- a/apps/tv-web/package.json +++ b/apps/tv-web/package.json @@ -1,6 +1,6 @@ { "name": "tv-web", - "version": "0.15.65", + "version": "0.15.66", "private": true, "type": "module", "scripts": { diff --git a/apps/tv-web/public/appinfo.json b/apps/tv-web/public/appinfo.json index ab17fe6f..e1bf4777 100644 --- a/apps/tv-web/public/appinfo.json +++ b/apps/tv-web/public/appinfo.json @@ -1,6 +1,6 @@ { "id": "com.airwave.tv", - "version": "0.15.65", + "version": "0.15.66", "vendor": "Airwave", "type": "web", "main": "index.html", diff --git a/apps/tv-web/public/config.xml b/apps/tv-web/public/config.xml index cfa38b8b..ccd5d891 100644 --- a/apps/tv-web/public/config.xml +++ b/apps/tv-web/public/config.xml @@ -10,7 +10,7 @@ of Samsung TVs (raise it only to use newer TV web APIs). NB: XML comments must not contain a double hyphen, so avoid CLI flags here. --> - + Airwave diff --git a/apps/web/package.json b/apps/web/package.json index 3d7fbd3b..8dca6279 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.15.65", + "version": "0.15.66", "private": true, "type": "module", "scripts": { diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 434a3b9c..965f28b4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -27,7 +27,7 @@ param( ) $ErrorActionPreference = "Stop" -$InstallerVersion = "0.15.65" # kept in lockstep with the app version by scripts/bump-version.ts +$InstallerVersion = "0.15.66" # kept in lockstep with the app version by scripts/bump-version.ts $ImageRepo = "ghcr.io/quixomatic/airwave" $ComposeUrl = if ($env:AIRWAVE_COMPOSE_URL) { $env:AIRWAVE_COMPOSE_URL } else { "https://www.getairwave.tv/docker-compose.yml" } $Marker = ".airwave-install" diff --git a/scripts/install.sh b/scripts/install.sh index 00f84394..66210de3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -21,7 +21,7 @@ set -eu # ---- Constants ------------------------------------------------------------- -INSTALLER_VERSION="0.15.65" # kept in lockstep with the app version by scripts/bump-version.ts +INSTALLER_VERSION="0.15.66" # kept in lockstep with the app version by scripts/bump-version.ts IMAGE_REPO="ghcr.io/quixomatic/airwave" COMPOSE_URL="${AIRWAVE_COMPOSE_URL:-https://www.getairwave.tv/docker-compose.yml}" MARKER=".airwave-install" From ddddc49e5e42dde6302e755533cc78fb82421cbf Mon Sep 17 00:00:00 2001 From: quixomatic Date: Tue, 13 Oct 2026 13:21:06 -0400 Subject: [PATCH 4/5] docs(tv-native): document sessionRoute's entry-gate decision --- apps/tv-native/src/lib/session-route.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/tv-native/src/lib/session-route.ts b/apps/tv-native/src/lib/session-route.ts index 39ebedd4..f27fa2b4 100644 --- a/apps/tv-native/src/lib/session-route.ts +++ b/apps/tv-native/src/lib/session-route.ts @@ -1,5 +1,10 @@ import { getToken, hasServerUrl } from "./auth"; +/** + * The entry-gate routing decision, in one place so `app/index.tsx` (and anything else that needs it) share + * one source of truth: no server → setup, a server but no token → login, otherwise the guide. Mirrors + * tv-web's `main.tsx` gate. Reads the in-memory session, so it's only correct after `loadSession()`. + */ export function sessionRoute(): "/setup" | "/login" | "/guide" { if (!hasServerUrl()) return "/setup"; return getToken() ? "/guide" : "/login"; From 71d43fbf8211ad7f70e8cede5191e8cad188b1d3 Mon Sep 17 00:00:00 2001 From: quixomatic Date: Tue, 13 Oct 2026 14:13:35 -0400 Subject: [PATCH 5/5] refactor(tv-native): scope the Apple TV fix to durable storage only The real bug is AsyncStorage being purged on tvOS; backing the session with UserDefaults (via RN Settings) fixes it, and the original boot is correct again. So drop the parts that overreached beyond persistence: the startup retry wall, the 401 auto-logout in api.ts, and the token-write serialization in auth.ts. The app/ screens revert to main byte-for-byte. auth.ts/cred-store keep only the storage swap plus clearing better-auth's session on sign-out / Change Server, all gated to Apple TV. Net change vs main is now just the four storage files. --- apps/tv-native/app/_layout.tsx | 46 ++++--------------------- apps/tv-native/app/index.tsx | 6 ++-- apps/tv-native/app/login.tsx | 20 +++-------- apps/tv-native/app/settings/server.tsx | 6 +--- apps/tv-native/app/settings/user.tsx | 6 +--- apps/tv-native/app/setup.tsx | 9 ++--- apps/tv-native/src/lib/api.ts | 13 ++----- apps/tv-native/src/lib/auth.ts | 41 ++++++---------------- apps/tv-native/src/lib/cred-store.ts | 23 ++++++------- apps/tv-native/src/lib/session-route.ts | 11 ------ apps/tv-native/src/lib/startup.ts | 11 ------ 11 files changed, 43 insertions(+), 149 deletions(-) delete mode 100644 apps/tv-native/src/lib/session-route.ts delete mode 100644 apps/tv-native/src/lib/startup.ts diff --git a/apps/tv-native/app/_layout.tsx b/apps/tv-native/app/_layout.tsx index f835e1ed..988592c4 100644 --- a/apps/tv-native/app/_layout.tsx +++ b/apps/tv-native/app/_layout.tsx @@ -4,18 +4,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { Component, type ReactNode, useEffect, useState } from "react"; -import { Dimensions, Platform, Pressable, Text, View } from "react-native"; +import { Dimensions, Platform, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { BootSplash } from "@/components/boot-splash"; import { PlayerProvider } from "@/features/watch/player-context"; -import { getToken, hasServerUrl } from "@/lib/auth"; +import { getToken, hasServerUrl, loadSession } from "@/lib/auth"; import { loadDevice } from "@/lib/device"; import { hydrateAudioMode } from "@/lib/audio-pref"; -import { LAYER, useKeyLayer, notifyInputActivity, useAndroidBack, useHardwareKeyInput, useTVInput } from "@/lib/input"; +import { notifyInputActivity, useAndroidBack, useHardwareKeyInput, useTVInput } from "@/lib/input"; import { hydrateNetwork, probeConnection } from "@/lib/plex-connection"; -import { initializeApp } from "@/lib/startup"; import { C } from "@/lib/theme"; // Startup diagnostic — isTV drives UI_SCALE + the focusable/native-focus gating; the window dp size + @@ -61,8 +60,6 @@ class SplashBoundary extends Component<{ children: ReactNode }, { failed: boolea */ export default function RootLayout() { const [ready, setReady] = useState(false); - const [storageError, setStorageError] = useState(false); - const [attempt, setAttempt] = useState(0); const [bootDone, setBootDone] = useState(false); // Input sources → the one dispatcher (tv-web's model). `useTVInput` = the TV remote D-pad (TV builds @@ -71,52 +68,21 @@ export default function RootLayout() { useTVInput(); useHardwareKeyInput(); useAndroidBack(); - const retry = () => { - setStorageError(false); - setAttempt((value) => value + 1); - }; - useKeyLayer({ - id: "startup-retry", - priority: LAYER.BASE, - active: storageError, - onKey(event) { - if (event.key !== "ok") return false; - retry(); - return true; - }, - }); useEffect(() => { - let active = true; - void initializeApp([loadDevice, hydrateNetwork, hydrateAudioMode]).then(() => { - if (!active) return; + void Promise.all([loadSession(), loadDevice(), hydrateNetwork(), hydrateAudioMode()]).finally(() => { setReady(true); // Refresh which Plex connection this device can actually reach (local → remote → relay) so // off-network playback streams from the right URL. Non-blocking; the first media request uses // the hydrated last-known value until this resolves. Only when we have a session to probe with. if (hasServerUrl() && getToken()) void probeConnection(); - }).catch(() => { - if (active) setStorageError(true); }); - return () => { - active = false; - }; - }, [attempt]); + }, []); // Hydrate session/device BEFORE mounting the app (route guards read the session synchronously). While // loading, a plain dark screen — it matches the splash background, so the hand-off is seamless. This is the // original, known-good boot; the splash is a pure overlay ON TOP of the mounted app. - if (!ready) return ( - - {storageError && <> - Could not read saved settings. - Retry to load your server and session. - - Retry - - } - - ); + if (!ready) return ; return ( notifyInputActivity()}> diff --git a/apps/tv-native/app/index.tsx b/apps/tv-native/app/index.tsx index aa112846..9bddb26d 100644 --- a/apps/tv-native/app/index.tsx +++ b/apps/tv-native/app/index.tsx @@ -1,6 +1,6 @@ import { Redirect } from "expo-router"; -import { sessionRoute } from "@/lib/session-route"; +import { getToken, hasServerUrl } from "@/lib/auth"; /** * Entry gate — the native analogue of tv-web's `main.tsx` routing + the `_auth` guard: @@ -9,5 +9,7 @@ import { sessionRoute } from "@/lib/session-route"; * - signed in → the guide (home) */ export default function Index() { - return ; + if (!hasServerUrl()) return ; + if (!getToken()) return ; + return ; } diff --git a/apps/tv-native/app/login.tsx b/apps/tv-native/app/login.tsx index ebe3d4de..2c53d238 100644 --- a/apps/tv-native/app/login.tsx +++ b/apps/tv-native/app/login.tsx @@ -54,12 +54,7 @@ export default function Login() { const res = await plexLink.poll(started.pinId); if (res.status === "ok") { stopPolling(); - try { - await setToken(res.token); - } catch { - reset("Couldn't save your session. Please try again."); - return; - } + await setToken(res.token); void probeConnection(); // pick the reachable Plex connection for this network router.replace("/guide"); } else if (res.status === "expired") { @@ -99,12 +94,7 @@ export default function Login() { }); if (tok?.access_token) { stopPolling(); - try { - await setToken(tok.access_token); - } catch { - reset("Couldn't save your session. Please try again."); - return; - } + await setToken(tok.access_token); void probeConnection(); // pick the reachable Plex connection for this network router.replace("/guide"); return; @@ -115,10 +105,10 @@ export default function Login() { }, (data.interval ?? 5) * 1000); }, [router]); - // Change server also clears any surviving bearer/Expo credentials before onboarding. + // "Change server" — drop the onboarded URL and bounce through the entry gate → onboarding. No token + // exists yet at login (mirrors settings/server.tsx's changeServer, minus the setToken(null)). const changeServer = useCallback(() => { - void setToken(null).then(clearServerUrl).then(() => router.replace("/")) - .catch(() => setError("Couldn't change server. Please try again.")); + void clearServerUrl().then(() => router.replace("/")); }, [router]); // D-pad zone machine. On Android TV our native key module consumes the D-pad and routes it here diff --git a/apps/tv-native/app/settings/server.tsx b/apps/tv-native/app/settings/server.tsx index f55fa5eb..18771c21 100644 --- a/apps/tv-native/app/settings/server.tsx +++ b/apps/tv-native/app/settings/server.tsx @@ -22,12 +22,9 @@ const NETWORK_LABEL: Record = { export default function ServerSettings() { const router = useRouter(); const [armChange, setArmChange] = useState(false); - const [storageError, setStorageError] = useState(false); const changeServer = () => { // Sign out + drop the stored server, then bounce through the entry gate → onboarding. - setStorageError(false); - void setToken(null).then(clearServerUrl).then(() => router.replace("/")) - .catch(() => setStorageError(true)); + void Promise.all([setToken(null), clearServerUrl()]).then(() => router.replace("/")); }; const [network, setNetwork] = useState(getNetwork()); @@ -68,7 +65,6 @@ export default function ServerSettings() { return ( - {storageError && Couldn't change server. Please try again.} diff --git a/apps/tv-native/app/settings/user.tsx b/apps/tv-native/app/settings/user.tsx index 5cdc0758..826b093c 100644 --- a/apps/tv-native/app/settings/user.tsx +++ b/apps/tv-native/app/settings/user.tsx @@ -21,11 +21,8 @@ export default function UserSettings() { const user = session?.user as { name?: string | null; email?: string; image?: string | null; role?: string | null } | undefined; const [armOut, setArmOut] = useState(false); - const [storageError, setStorageError] = useState(false); const signOut = () => { - setStorageError(false); - void setToken(null).then(() => router.replace("/login")) - .catch(() => setStorageError(true)); + void setToken(null).then(() => router.replace("/login")); }; const { sel } = useSettingsPage(1, () => { if (armOut) signOut(); @@ -38,7 +35,6 @@ export default function UserSettings() { return ( - {storageError && Couldn't sign out. Please try again.} diff --git a/apps/tv-native/app/setup.tsx b/apps/tv-native/app/setup.tsx index dc789106..ae672350 100644 --- a/apps/tv-native/app/setup.tsx +++ b/apps/tv-native/app/setup.tsx @@ -32,13 +32,8 @@ export default function Setup() { const inputRef = useRef(null); const save = async (target: string) => { - try { - await setServerUrl(target); - router.replace("/login"); - } catch { - setChecking(false); - setError("Couldn't save your server address. Please try again."); - } + await setServerUrl(target); + router.replace("/login"); }; const connect = async () => { diff --git a/apps/tv-native/src/lib/api.ts b/apps/tv-native/src/lib/api.ts index 452ef636..dd0000b2 100644 --- a/apps/tv-native/src/lib/api.ts +++ b/apps/tv-native/src/lib/api.ts @@ -1,6 +1,4 @@ -import { router } from "expo-router"; - -import { getServerUrl, getToken, invalidateToken } from "./auth"; +import { getServerUrl, getToken } from "./auth"; import { getNetwork } from "./plex-connection"; /** @@ -23,8 +21,7 @@ export class ApiError extends Error { async function request(path: string, init: RequestInit = {}): Promise { const token = getToken(); - const server = getServerUrl(); - const res = await fetch(`${server}${path}`, { + const res = await fetch(`${getServerUrl()}${path}`, { ...init, headers: { "Content-Type": "application/json", @@ -33,12 +30,6 @@ async function request(path: string, init: RequestInit = {}): Promise { }, }); if (!res.ok) { - // Only reject the current credential on 401; retain it on network/5xx/permissions failures. - // A late response from a previous login or server must not invalidate the new session. - if (res.status === 401 && token && token === getToken() && server === getServerUrl()) { - const invalidated = await invalidateToken(token, server); - if (invalidated && server === getServerUrl() && !getToken()) router.replace("/login"); - } const body = (await res.json().catch(() => null)) as { error?: string } | null; throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`); } diff --git a/apps/tv-native/src/lib/auth.ts b/apps/tv-native/src/lib/auth.ts index c5c76dd9..1aca5786 100644 --- a/apps/tv-native/src/lib/auth.ts +++ b/apps/tv-native/src/lib/auth.ts @@ -24,22 +24,16 @@ const BAKED = (process.env.EXPO_PUBLIC_SERVER_URL ?? "").replace(/\/+$/, ""); let serverUrl = BAKED; let token: string | null = null; -let tokenWrites: Promise = Promise.resolve(); /** Hydrate the in-memory session from storage. Call once before rendering the app. */ export async function loadSession(): Promise { - const [urlResult, tokenResult, expoResult] = await Promise.allSettled([ + const [storedUrl, storedToken] = await Promise.all([ SessionStorage.getItem(SERVER_KEY), CredStore.getItemAsync(TOKEN_KEY), - // Migrate Apple-TV Expo credentials before any auth screen renders. + // Migrate the Apple-TV better-auth credentials into the durable store before any auth screen reads them. hydrateSyncCreds(), ]); - if (urlResult.status !== "fulfilled" || tokenResult.status !== "fulfilled" || expoResult.status !== "fulfilled") { - throw new Error("Could not read saved session"); - } - const storedUrl = urlResult.value; - const storedToken = tokenResult.value; - serverUrl = storedUrl ? storedUrl.replace(/\/+$/, "") : BAKED; + if (storedUrl) serverUrl = storedUrl.replace(/\/+$/, ""); token = storedToken ?? null; } @@ -62,28 +56,15 @@ export async function clearServerUrl(): Promise { export function getToken(): string | null { return token; } -function writeToken(next: string | null, rejected?: { token: string; server: string }): Promise { - const write = tokenWrites.then(async () => { - if (rejected && (token !== rejected.token || serverUrl !== rejected.server)) return false; - if (next) await CredStore.setItemAsync(TOKEN_KEY, next); - else { - await clearSyncCreds(); - await CredStore.deleteItemAsync(TOKEN_KEY); - } - token = next; - return true; - }); - // Serialize deletion with a concurrent login; failed writes must not block a later retry. - tokenWrites = write.catch(() => {}); - return write; -} - export async function setToken(next: string | null): Promise { - await writeToken(next); -} - -export function invalidateToken(rejectedToken: string, rejectedServer: string): Promise { - return writeToken(null, { token: rejectedToken, server: rejectedServer }); + if (next) { + await CredStore.setItemAsync(TOKEN_KEY, next); + } else { + // Sign-out: clear better-auth's cookie/session alongside the bearer token so no stale session lingers. + await clearSyncCreds(); + await CredStore.deleteItemAsync(TOKEN_KEY); + } + token = next; } /** Coerce user input into a base URL (add a scheme if missing, drop a trailing slash) — tv-web parity. */ diff --git a/apps/tv-native/src/lib/cred-store.ts b/apps/tv-native/src/lib/cred-store.ts index fd0953e2..02a67257 100644 --- a/apps/tv-native/src/lib/cred-store.ts +++ b/apps/tv-native/src/lib/cred-store.ts @@ -17,27 +17,26 @@ export const CredStore = isAppleTV deleteItemAsync: (key: string) => SecureStore.deleteItemAsync(key), }; -// @better-auth/expo 1.6 uses these two keys with storagePrefix: "cg". Migrate only -// those values, rather than copying all device preferences into credential storage. +// @better-auth/expo 1.6 stores its session under these two keys (storagePrefix "cg"). We migrate only +// those, rather than copying every device preference into credential storage. const SYNC_KEYS = ["cg_cookie", "cg_session_data"]; +/** Trigger the one-time AsyncStorage→UserDefaults migration of the better-auth session at startup, so the + * synchronous reads below see it. Best-effort: UserDefaults reads don't fail, and a missing value just + * means "signed out". No-op off Apple TV. */ export async function hydrateSyncCreds(): Promise { if (!isAppleTV) return; - const results = await Promise.allSettled(SYNC_KEYS.map((key) => SessionStorage.getItem(key))); - if (results.some((result) => result.status === "rejected")) { - throw new Error("Could not read saved credentials"); - } + await Promise.allSettled(SYNC_KEYS.map((key) => SessionStorage.getItem(key))); } +/** Clear the better-auth session (cookie + session data) on sign-out. No-op off Apple TV. */ export async function clearSyncCreds(): Promise { - const results = await Promise.allSettled(SYNC_KEYS.map((key) => CredStore.deleteItemAsync(key))); - if (results.some((result) => result.status === "rejected")) { - throw new Error("Could not clear saved credentials"); - } + if (!isAppleTV) return; + await Promise.allSettled(SYNC_KEYS.map((key) => CredStore.deleteItemAsync(key))); } -/** The Expo auth client needs synchronous reads. Both credential interfaces now use - * the same persistent tvOS store, without a stale cache or fire-and-forget writes. */ +/** The Expo auth client reads its session SYNCHRONOUSLY. On Apple TV both credential interfaces share the + * same durable UserDefaults store (no stale in-memory cache); elsewhere it's SecureStore's sync accessors. */ export const SyncCredStore = isAppleTV ? TVSessionStorage : { diff --git a/apps/tv-native/src/lib/session-route.ts b/apps/tv-native/src/lib/session-route.ts deleted file mode 100644 index f27fa2b4..00000000 --- a/apps/tv-native/src/lib/session-route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getToken, hasServerUrl } from "./auth"; - -/** - * The entry-gate routing decision, in one place so `app/index.tsx` (and anything else that needs it) share - * one source of truth: no server → setup, a server but no token → login, otherwise the guide. Mirrors - * tv-web's `main.tsx` gate. Reads the in-memory session, so it's only correct after `loadSession()`. - */ -export function sessionRoute(): "/setup" | "/login" | "/guide" { - if (!hasServerUrl()) return "/setup"; - return getToken() ? "/guide" : "/login"; -} diff --git a/apps/tv-native/src/lib/startup.ts b/apps/tv-native/src/lib/startup.ts deleted file mode 100644 index cc0241d9..00000000 --- a/apps/tv-native/src/lib/startup.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { loadSession } from "./auth"; - -/** Wait for every hydration task to settle before retrying. Promise.all's early rejection - * otherwise lets a retry overlap still-running hydration and overwrite newer state. */ -export async function initializeApp(otherHydrations: Array<() => Promise>): Promise { - const results = await Promise.allSettled([loadSession(), ...otherHydrations.map((load) => load())]); - if (results.some((result) => result.status === "rejected")) { - // Native/storage errors can contain sensitive values. Surface only a fixed message. - throw new Error("Could not read saved settings. Please retry."); - } -}