diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 234df4b7..0d1dbab9 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -27,7 +27,7 @@ import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device' import { useHardwareKeyboard } from '@/lib/hardwareKeyboard'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint, MOBILE_DISCONNECTED_RUNTIME_KEY } from '@/lib/runtime-switch'; import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache'; import { cn } from '@/lib/utils'; @@ -686,7 +686,7 @@ export function MobileApp({ apis }: MobileAppProps) { }; const disconnect = (reason: string) => { logMobileConnectEvent('resume:disconnect', { reason }); - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }; @@ -896,7 +896,7 @@ export function MobileApp({ apis }: MobileAppProps) { const dropToConnectScreen = (notice: MobileConnectionNotice | null) => { logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' }); if (notice) setAutoConnectNotice(notice); - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }; void reprobeActiveConnection().then(async (outcome) => { @@ -1196,7 +1196,7 @@ export function MobileApp({ apis }: MobileAppProps) { type="button" variant="outline" onClick={() => { - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }} > @@ -1279,7 +1279,7 @@ export function MobileApp({ apis }: MobileAppProps) { { - switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY }); setConnectionEpoch((value) => value + 1); }} /> diff --git a/packages/ui/src/contexts/theme-storage.test.ts b/packages/ui/src/contexts/theme-storage.test.ts index 92acc2ba..ecba5ad0 100644 --- a/packages/ui/src/contexts/theme-storage.test.ts +++ b/packages/ui/src/contexts/theme-storage.test.ts @@ -5,12 +5,12 @@ import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/theme import { adoptThemePreferencesForRuntime, getThemePreferencesStorageKey, - isTransientRuntimeKey, readThemePreferencesForRuntime, resolveThemePreferencesForRuntime, resolveThemePreferencesFromStorageEvent, writeThemePreferencesForRuntime, } from './theme-storage'; +import { isTransientRuntimeKey } from '@/lib/runtime-switch'; let createdWindow = false; let createdLocalStorage = false; diff --git a/packages/ui/src/contexts/theme-storage.ts b/packages/ui/src/contexts/theme-storage.ts index 8fc94ef6..863b9efc 100644 --- a/packages/ui/src/contexts/theme-storage.ts +++ b/packages/ui/src/contexts/theme-storage.ts @@ -1,5 +1,6 @@ import type { ThemeMode } from '@/types/theme'; import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes'; +import { isTransientRuntimeKey } from '@/lib/runtime-switch'; type StoredThemePreferences = { themeMode: ThemeMode; @@ -21,56 +22,56 @@ const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:'; export const getThemePreferencesStorageKey = (runtimeKey: string): string => `${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`; -// Runtime keys that mean "no instance connected" — the uninitialized default -// and the mobile disconnect state. They carry no instance theme, so scoped -// storage must not read or write them: a write would pin whatever theme was -// current at that moment (e.g. cold-boot defaults) to a key every future -// launch resolves before connecting, and a read would surface that stale -// entry on the mobile connect splash. The global splash hints are the right -// fallback for those phases. -const TRANSIENT_RUNTIME_KEYS = new Set(['', 'url:default', 'mobile-disconnected']); +const THEME_MODES: readonly ThemeMode[] = ['light', 'dark', 'system']; -export const isTransientRuntimeKey = (runtimeKey: string): boolean => - TRANSIENT_RUNTIME_KEYS.has(runtimeKey); +const isThemeMode = (value: string): value is ThemeMode => + THEME_MODES.some((mode) => mode === value); -export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => { - if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { - return null; - } - let raw: string | null = null; +// Boundary parser for the scoped entry. A malformed or partial payload is a +// failure (`null`), never a valid default: the caller then falls back to the +// legacy seed or keeps its current preferences. +const parseStoredThemePreferences = (raw: string): StoredThemePreferences | null => { try { - raw = localStorage.getItem(getThemePreferencesStorageKey(runtimeKey)); - } catch { - return null; - } - if (!raw) { - return null; - } - try { - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object') { + // SAFETY: this key is written only by `writeThemePreferencesForRuntime` + // with exactly this shape. Every field is still re-checked below, and a + // field of the wrong type throws on `.trim()` into the catch. + const candidate = JSON.parse(raw) as Partial | null; + if (candidate === null) { return null; } - const candidate = parsed as Record; - if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') { + const themeMode = candidate.themeMode ?? ''; + if (!isThemeMode(themeMode)) { return null; } - if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') { - return null; - } - const lightThemeId = candidate.lightThemeId.trim(); - const darkThemeId = candidate.darkThemeId.trim(); + const lightThemeId = (candidate.lightThemeId ?? '').trim(); + const darkThemeId = (candidate.darkThemeId ?? '').trim(); if (!lightThemeId || !darkThemeId) { return null; } - return { themeMode: candidate.themeMode, lightThemeId, darkThemeId }; + return { themeMode, lightThemeId, darkThemeId }; } catch { return null; } }; +const readLocalStorageItem = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => { + if (isTransientRuntimeKey(runtimeKey)) { + return null; + } + const raw = readLocalStorageItem(getThemePreferencesStorageKey(runtimeKey)); + return raw ? parseStoredThemePreferences(raw) : null; +}; + export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => { - if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { + if (isTransientRuntimeKey(runtimeKey)) { return; } try { @@ -118,16 +119,12 @@ const readLegacyThemePreferences = (): StoredThemePreferences => { let lightThemeId: string = DEFAULT_LIGHT_THEME_ID; let darkThemeId: string = DEFAULT_DARK_THEME_ID; - if (typeof window === 'undefined') { - return { themeMode, lightThemeId, darkThemeId }; - } + const legacyMode = readLocalStorageItem('themeMode'); + const legacyUseSystem = readLocalStorageItem('useSystemTheme'); + const legacyThemeId = readLocalStorageItem('selectedThemeId'); + const legacyVariant = readLocalStorageItem('selectedThemeVariant'); - const legacyMode = localStorage.getItem('themeMode'); - const legacyUseSystem = localStorage.getItem('useSystemTheme'); - const legacyThemeId = localStorage.getItem('selectedThemeId'); - const legacyVariant = localStorage.getItem('selectedThemeVariant'); - - if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') { + if (legacyMode !== null && isThemeMode(legacyMode)) { themeMode = legacyMode; } else if (legacyUseSystem !== null) { const useSystem = legacyUseSystem === 'true'; @@ -148,13 +145,13 @@ const readLegacyThemePreferences = (): StoredThemePreferences => { themeMode = legacyVariant; } - const legacyLightId = localStorage.getItem('lightThemeId'); - const legacyDarkId = localStorage.getItem('darkThemeId'); - if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) { - lightThemeId = legacyLightId.trim(); + const legacyLightId = readLocalStorageItem('lightThemeId')?.trim(); + const legacyDarkId = readLocalStorageItem('darkThemeId')?.trim(); + if (legacyLightId) { + lightThemeId = legacyLightId; } - if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) { - darkThemeId = legacyDarkId.trim(); + if (legacyDarkId) { + darkThemeId = legacyDarkId; } return { themeMode, lightThemeId, darkThemeId }; diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 8b11ef90..a6a6eecc 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -51,6 +51,17 @@ const normalizeRuntimeUrlKey = (value: string): string => { } }; +// Runtime keys that mean "no instance connected": the uninitialized default +// (`normalizeRuntimeUrlKey` of an empty/unparseable base URL) and the mobile +// disconnect state (`MobileApp` switches to it when the connection drops). +// Per-instance client state (e.g. the scoped theme entry) must not be read +// from or written under them. +export const MOBILE_DISCONNECTED_RUNTIME_KEY = 'mobile-disconnected'; +const UNINITIALIZED_RUNTIME_KEY = 'url:default'; + +export const isTransientRuntimeKey = (runtimeKey: string): boolean => + runtimeKey === '' || runtimeKey === UNINITIALIZED_RUNTIME_KEY || runtimeKey === MOBILE_DISCONNECTED_RUNTIME_KEY; + const readInjectedApiBaseUrl = (): string => { if (typeof window === 'undefined') return ''; const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;