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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop-setup/package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.15.65",
"version": "0.15.66",
"type": "module",
"main": "src/index.ts",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "site",
"version": "0.15.65",
"version": "0.15.66",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/tv-native/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"expo": {
"name": "Airwave",
"slug": "airwave",
"version": "0.15.65",
"version": "0.15.66",
"scheme": "airwave",
"orientation": "landscape",
"userInterfaceStyle": "dark",
Expand Down
2 changes: 1 addition & 1 deletion apps/tv-native/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tv-native",
"version": "0.15.65",
"version": "0.15.66",
"private": true,
"main": "expo-router/entry",
"scripts": {
Expand Down
29 changes: 17 additions & 12 deletions apps/tv-native/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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:
* the native app is a bearer-token client that talks to the SAME REST surface, so all it needs is
* (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
Expand All @@ -29,9 +28,9 @@ let token: string | null = null;
/** Hydrate the in-memory session from storage. Call once before rendering the app. */
export async function loadSession(): Promise<void> {
const [storedUrl, storedToken] = await Promise.all([
AsyncStorage.getItem(SERVER_KEY),
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 the Apple-TV better-auth credentials into the durable store before any auth screen reads them.
hydrateSyncCreds(),
]);
if (storedUrl) serverUrl = storedUrl.replace(/\/+$/, "");
Expand All @@ -45,21 +44,27 @@ export function hasServerUrl(): boolean {
return serverUrl !== "";
}
export async function setServerUrl(raw: string): Promise<void> {
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<void> {
await SessionStorage.removeItem(SERVER_KEY);
serverUrl = BAKED;
await AsyncStorage.removeItem(SERVER_KEY);
}

export function getToken(): string | null {
return token;
}
export async function setToken(next: string | null): Promise<void> {
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;
if (next) await CredStore.setItemAsync(TOKEN_KEY, next);
else await CredStore.deleteItemAsync(TOKEN_KEY);
}

/** Coerce user input into a base URL (add a scheme if missing, drop a trailing slash) — tv-web parity. */
Expand Down
93 changes: 23 additions & 70 deletions apps/tv-native/src/lib/cred-store.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,44 @@
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),
setItemAsync: (key: string, value: string) => SecureStore.setItemAsync(key, value),
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<string, string>();
// @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"];

/** Populate the Apple-TV sync cache from AsyncStorage. No-op elsewhere. Awaited at startup in loadSession. */
/** 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<void> {
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)}`);
}
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<void> {
if (!isAppleTV) return;
await Promise.allSettled(SYNC_KEYS.map((key) => CredStore.deleteItemAsync(key)));
}

/** 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
? {
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),
Expand Down
17 changes: 9 additions & 8 deletions apps/tv-native/src/lib/device.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";
Expand All @@ -23,12 +23,12 @@ function gen(): string {

/** Hydrate device identity + caps-done. Call once at startup (alongside loadSession). */
export async function loadDevice(): Promise<void> {
_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 {
Expand All @@ -41,8 +41,9 @@ export function capsDoneForCurrentServer(): boolean {
}

export async function markCapsDone(): Promise<void> {
_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
Expand Down
64 changes: 64 additions & 0 deletions apps/tv-native/src/lib/session-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform, Settings } from "react-native";

export const isAppleTV = Platform.OS === "ios" && Platform.isTV;

/**
* 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 {
const value = Settings.get(key);
return typeof value === "string" && value !== "" ? value : null;
},
setItem(key: string, value: string): void {
Settings.set({ [key]: value });
},
};

/**
* 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<string | 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 ?? ""); // tombstone when the legacy value is also gone
return legacy;
}

export const SessionStorage = isAppleTV
? {
getItem: read,
async setItem(key: string, value: string): Promise<void> {
TVSessionStorage.setItem(key, value);
},
async removeItem(key: string): Promise<void> {
// 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, "");
},
}
: AsyncStorage;
2 changes: 1 addition & 1 deletion apps/tv-roku/manifest
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/tv-roku/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/tv-tauri/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tv-tauri",
"version": "0.15.65",
"version": "0.15.66",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/tv-tauri/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading