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__;