refactor(ui): parse the scoped theme entry at the boundary; runtime-switch owns transient keys
The scoped theme entry is now parsed by one boundary parser with a stated invariant instead of ad hoc typeof narrowing, and the runtime keys that mean "no instance connected" live next to the code that produces them so a new sentinel cannot miss the theme-storage guard.
This commit is contained in:
@@ -27,7 +27,7 @@ import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device'
|
|||||||
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
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 { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache';
|
import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -686,7 +686,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
};
|
};
|
||||||
const disconnect = (reason: string) => {
|
const disconnect = (reason: string) => {
|
||||||
logMobileConnectEvent('resume:disconnect', { reason });
|
logMobileConnectEvent('resume:disconnect', { reason });
|
||||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
|
||||||
setConnectionEpoch((value) => value + 1);
|
setConnectionEpoch((value) => value + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -896,7 +896,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
|
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
|
||||||
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
|
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
|
||||||
if (notice) setAutoConnectNotice(notice);
|
if (notice) setAutoConnectNotice(notice);
|
||||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
|
||||||
setConnectionEpoch((value) => value + 1);
|
setConnectionEpoch((value) => value + 1);
|
||||||
};
|
};
|
||||||
void reprobeActiveConnection().then(async (outcome) => {
|
void reprobeActiveConnection().then(async (outcome) => {
|
||||||
@@ -1196,7 +1196,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
|
||||||
setConnectionEpoch((value) => value + 1);
|
setConnectionEpoch((value) => value + 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1279,7 +1279,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
<OpenCodeUpdateToast />
|
<OpenCodeUpdateToast />
|
||||||
<MobileAppUpdateToast />
|
<MobileAppUpdateToast />
|
||||||
<MobileShell onActiveConnectionDeleted={() => {
|
<MobileShell onActiveConnectionDeleted={() => {
|
||||||
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
|
||||||
setConnectionEpoch((value) => value + 1);
|
setConnectionEpoch((value) => value + 1);
|
||||||
}} />
|
}} />
|
||||||
<AppLinkConfirmDialog />
|
<AppLinkConfirmDialog />
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/theme
|
|||||||
import {
|
import {
|
||||||
adoptThemePreferencesForRuntime,
|
adoptThemePreferencesForRuntime,
|
||||||
getThemePreferencesStorageKey,
|
getThemePreferencesStorageKey,
|
||||||
isTransientRuntimeKey,
|
|
||||||
readThemePreferencesForRuntime,
|
readThemePreferencesForRuntime,
|
||||||
resolveThemePreferencesForRuntime,
|
resolveThemePreferencesForRuntime,
|
||||||
resolveThemePreferencesFromStorageEvent,
|
resolveThemePreferencesFromStorageEvent,
|
||||||
writeThemePreferencesForRuntime,
|
writeThemePreferencesForRuntime,
|
||||||
} from './theme-storage';
|
} from './theme-storage';
|
||||||
|
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
let createdWindow = false;
|
let createdWindow = false;
|
||||||
let createdLocalStorage = false;
|
let createdLocalStorage = false;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ThemeMode } from '@/types/theme';
|
import type { ThemeMode } from '@/types/theme';
|
||||||
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes';
|
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes';
|
||||||
|
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
type StoredThemePreferences = {
|
type StoredThemePreferences = {
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -21,56 +22,56 @@ const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:';
|
|||||||
export const getThemePreferencesStorageKey = (runtimeKey: string): string =>
|
export const getThemePreferencesStorageKey = (runtimeKey: string): string =>
|
||||||
`${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
|
`${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
|
||||||
|
|
||||||
// Runtime keys that mean "no instance connected" — the uninitialized default
|
const THEME_MODES: readonly ThemeMode[] = ['light', 'dark', 'system'];
|
||||||
// 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']);
|
|
||||||
|
|
||||||
export const isTransientRuntimeKey = (runtimeKey: string): boolean =>
|
const isThemeMode = (value: string): value is ThemeMode =>
|
||||||
TRANSIENT_RUNTIME_KEYS.has(runtimeKey);
|
THEME_MODES.some((mode) => mode === value);
|
||||||
|
|
||||||
export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => {
|
// Boundary parser for the scoped entry. A malformed or partial payload is a
|
||||||
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) {
|
// failure (`null`), never a valid default: the caller then falls back to the
|
||||||
return null;
|
// legacy seed or keeps its current preferences.
|
||||||
}
|
const parseStoredThemePreferences = (raw: string): StoredThemePreferences | null => {
|
||||||
let raw: string | null = null;
|
|
||||||
try {
|
try {
|
||||||
raw = localStorage.getItem(getThemePreferencesStorageKey(runtimeKey));
|
// SAFETY: this key is written only by `writeThemePreferencesForRuntime`
|
||||||
} catch {
|
// with exactly this shape. Every field is still re-checked below, and a
|
||||||
return null;
|
// field of the wrong type throws on `.trim()` into the catch.
|
||||||
}
|
const candidate = JSON.parse(raw) as Partial<StoredThemePreferences> | null;
|
||||||
if (!raw) {
|
if (candidate === null) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
if (!parsed || typeof parsed !== 'object') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const candidate = parsed as Record<string, unknown>;
|
const themeMode = candidate.themeMode ?? '';
|
||||||
if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') {
|
if (!isThemeMode(themeMode)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') {
|
const lightThemeId = (candidate.lightThemeId ?? '').trim();
|
||||||
return null;
|
const darkThemeId = (candidate.darkThemeId ?? '').trim();
|
||||||
}
|
|
||||||
const lightThemeId = candidate.lightThemeId.trim();
|
|
||||||
const darkThemeId = candidate.darkThemeId.trim();
|
|
||||||
if (!lightThemeId || !darkThemeId) {
|
if (!lightThemeId || !darkThemeId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { themeMode: candidate.themeMode, lightThemeId, darkThemeId };
|
return { themeMode, lightThemeId, darkThemeId };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 => {
|
export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => {
|
||||||
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) {
|
if (isTransientRuntimeKey(runtimeKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -118,16 +119,12 @@ const readLegacyThemePreferences = (): StoredThemePreferences => {
|
|||||||
let lightThemeId: string = DEFAULT_LIGHT_THEME_ID;
|
let lightThemeId: string = DEFAULT_LIGHT_THEME_ID;
|
||||||
let darkThemeId: string = DEFAULT_DARK_THEME_ID;
|
let darkThemeId: string = DEFAULT_DARK_THEME_ID;
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
const legacyMode = readLocalStorageItem('themeMode');
|
||||||
return { themeMode, lightThemeId, darkThemeId };
|
const legacyUseSystem = readLocalStorageItem('useSystemTheme');
|
||||||
}
|
const legacyThemeId = readLocalStorageItem('selectedThemeId');
|
||||||
|
const legacyVariant = readLocalStorageItem('selectedThemeVariant');
|
||||||
|
|
||||||
const legacyMode = localStorage.getItem('themeMode');
|
if (legacyMode !== null && isThemeMode(legacyMode)) {
|
||||||
const legacyUseSystem = localStorage.getItem('useSystemTheme');
|
|
||||||
const legacyThemeId = localStorage.getItem('selectedThemeId');
|
|
||||||
const legacyVariant = localStorage.getItem('selectedThemeVariant');
|
|
||||||
|
|
||||||
if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') {
|
|
||||||
themeMode = legacyMode;
|
themeMode = legacyMode;
|
||||||
} else if (legacyUseSystem !== null) {
|
} else if (legacyUseSystem !== null) {
|
||||||
const useSystem = legacyUseSystem === 'true';
|
const useSystem = legacyUseSystem === 'true';
|
||||||
@@ -148,13 +145,13 @@ const readLegacyThemePreferences = (): StoredThemePreferences => {
|
|||||||
themeMode = legacyVariant;
|
themeMode = legacyVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyLightId = localStorage.getItem('lightThemeId');
|
const legacyLightId = readLocalStorageItem('lightThemeId')?.trim();
|
||||||
const legacyDarkId = localStorage.getItem('darkThemeId');
|
const legacyDarkId = readLocalStorageItem('darkThemeId')?.trim();
|
||||||
if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) {
|
if (legacyLightId) {
|
||||||
lightThemeId = legacyLightId.trim();
|
lightThemeId = legacyLightId;
|
||||||
}
|
}
|
||||||
if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) {
|
if (legacyDarkId) {
|
||||||
darkThemeId = legacyDarkId.trim();
|
darkThemeId = legacyDarkId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { themeMode, lightThemeId, darkThemeId };
|
return { themeMode, lightThemeId, darkThemeId };
|
||||||
|
|||||||
@@ -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 => {
|
const readInjectedApiBaseUrl = (): string => {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;
|
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;
|
||||||
|
|||||||
Reference in New Issue
Block a user