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.");
- }
-}