fix(ui): scope theme settings per runtime instance (#2897)
Closes #2958
This commit is contained in:
@@ -70,6 +70,27 @@ export const useNativeMobileChrome = (): void => {
|
||||
const retry = window.setTimeout(() => void applyStatusBar(), 400);
|
||||
cleanup.push(() => window.clearTimeout(retry));
|
||||
|
||||
// Theme toggles must reach the status bar without an app restart: re-run
|
||||
// whenever the root dark/light class flips — the one signal every theme
|
||||
// path converges on (settings toggle, synced settings, storage events,
|
||||
// system-preference changes while in system mode). splashBg* colors are
|
||||
// per-variant values, so they are stable across mode toggles.
|
||||
if (platform === 'android') {
|
||||
let wasDark = root.classList.contains('dark');
|
||||
const themeClassObserver = new MutationObserver(() => {
|
||||
const isDark = root.classList.contains('dark');
|
||||
if (isDark === wasDark) return;
|
||||
wasDark = isDark;
|
||||
void applyStatusBar();
|
||||
});
|
||||
themeClassObserver.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
if (disposed) {
|
||||
themeClassObserver.disconnect();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => themeClassObserver.disconnect());
|
||||
}
|
||||
|
||||
const { App } = await import('@capacitor/app');
|
||||
const stateHandle = await App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void applyStatusBar();
|
||||
|
||||
@@ -31,6 +31,12 @@ import {
|
||||
import { isValidTheme } from './theme-validation';
|
||||
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import {
|
||||
adoptThemePreferencesForRuntime,
|
||||
resolveThemePreferencesForRuntime,
|
||||
resolveThemePreferencesFromStorageEvent,
|
||||
writeThemePreferencesForRuntime,
|
||||
} from './theme-storage';
|
||||
|
||||
type ThemePreferences = {
|
||||
themeMode: ThemeMode;
|
||||
@@ -87,46 +93,27 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
||||
const embeddedMode = embeddedParams?.get('themeMode');
|
||||
const embeddedLightId = embeddedParams?.get('lightThemeId');
|
||||
const embeddedDarkId = embeddedParams?.get('darkThemeId');
|
||||
const storedMode = localStorage.getItem('themeMode');
|
||||
const storedLightId = localStorage.getItem('lightThemeId');
|
||||
const storedDarkId = localStorage.getItem('darkThemeId');
|
||||
const legacyUseSystem = localStorage.getItem('useSystemTheme');
|
||||
const legacyThemeId = localStorage.getItem('selectedThemeId');
|
||||
const legacyVariant = localStorage.getItem('selectedThemeVariant');
|
||||
// Scoped entry when present; otherwise a one-time seed from the superseded
|
||||
// global keys (see resolveThemePreferencesForRuntime), so the first scoped
|
||||
// write carries the last-known theme instead of defaults.
|
||||
const resolvedPreferences = resolveThemePreferencesForRuntime(getRuntimeKey());
|
||||
|
||||
if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') {
|
||||
themeMode = embeddedMode;
|
||||
} else if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') {
|
||||
themeMode = storedMode;
|
||||
} else if (legacyUseSystem !== null) {
|
||||
const useSystem = legacyUseSystem === 'true';
|
||||
if (useSystem) {
|
||||
themeMode = 'system';
|
||||
} else if (legacyThemeId) {
|
||||
const legacyTheme = getThemeById(legacyThemeId);
|
||||
if (legacyTheme) {
|
||||
themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light';
|
||||
if (legacyTheme.metadata.variant === 'dark') {
|
||||
darkThemeId = legacyTheme.metadata.id;
|
||||
} else {
|
||||
lightThemeId = legacyTheme.metadata.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (legacyVariant === 'light' || legacyVariant === 'dark') {
|
||||
themeMode = legacyVariant;
|
||||
} else {
|
||||
themeMode = resolvedPreferences.themeMode;
|
||||
}
|
||||
|
||||
if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) {
|
||||
lightThemeId = embeddedLightId.trim();
|
||||
} else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) {
|
||||
lightThemeId = storedLightId.trim();
|
||||
} else {
|
||||
lightThemeId = resolvedPreferences.lightThemeId;
|
||||
}
|
||||
|
||||
if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) {
|
||||
darkThemeId = embeddedDarkId.trim();
|
||||
} else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) {
|
||||
darkThemeId = storedDarkId.trim();
|
||||
} else {
|
||||
darkThemeId = resolvedPreferences.darkThemeId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +301,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
customThemesRequestRef.current += 1;
|
||||
setCustomThemes([]);
|
||||
setCustomThemesLoading(false);
|
||||
// Adopt the new instance's last-known theme immediately; the incoming
|
||||
// settings sync refines it with the server's authoritative value.
|
||||
setPreferences((prev) => adoptThemePreferencesForRuntime(detail.runtimeKey, prev));
|
||||
void reloadCustomThemes();
|
||||
}), [isVSCode, reloadCustomThemes]);
|
||||
|
||||
@@ -424,6 +414,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return;
|
||||
}
|
||||
|
||||
writeThemePreferencesForRuntime(getRuntimeKey(), {
|
||||
themeMode: preferences.themeMode,
|
||||
lightThemeId: preferences.lightThemeId,
|
||||
darkThemeId: preferences.darkThemeId,
|
||||
});
|
||||
|
||||
// Cosmetic last-writer-wins hints for the pre-React splash shells
|
||||
// (packages/web/index.html, mobile.html, mini-chat.html) and the Android
|
||||
// status bar, which run before the scoped key can be read. Not part of the
|
||||
// app's theme authority — the scoped entry and the per-instance server
|
||||
// settings own that.
|
||||
localStorage.setItem('themeMode', preferences.themeMode);
|
||||
localStorage.setItem('lightThemeId', preferences.lightThemeId);
|
||||
localStorage.setItem('darkThemeId', preferences.darkThemeId);
|
||||
@@ -434,8 +435,6 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
|
||||
);
|
||||
|
||||
// Splash screen (packages/web/index.html) runs before the theme CSS vars load.
|
||||
// Persist just enough to theme it on next boot.
|
||||
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
|
||||
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
||||
|
||||
@@ -459,37 +458,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'themeMode' && event.key !== 'lightThemeId' && event.key !== 'darkThemeId') {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreferences((prev) => {
|
||||
const nextModeRaw = localStorage.getItem('themeMode');
|
||||
const nextMode: ThemeMode =
|
||||
nextModeRaw === 'light' || nextModeRaw === 'dark' || nextModeRaw === 'system'
|
||||
? nextModeRaw
|
||||
: prev.themeMode;
|
||||
|
||||
const nextLightRaw = localStorage.getItem('lightThemeId');
|
||||
const nextLight = typeof nextLightRaw === 'string' && nextLightRaw.trim().length > 0
|
||||
? nextLightRaw.trim()
|
||||
: prev.lightThemeId;
|
||||
|
||||
const nextDarkRaw = localStorage.getItem('darkThemeId');
|
||||
const nextDark = typeof nextDarkRaw === 'string' && nextDarkRaw.trim().length > 0
|
||||
? nextDarkRaw.trim()
|
||||
: prev.darkThemeId;
|
||||
|
||||
if (nextMode === prev.themeMode && nextLight === prev.lightThemeId && nextDark === prev.darkThemeId) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
themeMode: nextMode,
|
||||
lightThemeId: nextLight,
|
||||
darkThemeId: nextDark,
|
||||
};
|
||||
});
|
||||
setPreferences((prev) => resolveThemePreferencesFromStorageEvent(event.key, getRuntimeKey(), prev) ?? prev);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
|
||||
|
||||
import {
|
||||
adoptThemePreferencesForRuntime,
|
||||
getThemePreferencesStorageKey,
|
||||
isTransientRuntimeKey,
|
||||
readThemePreferencesForRuntime,
|
||||
resolveThemePreferencesForRuntime,
|
||||
resolveThemePreferencesFromStorageEvent,
|
||||
writeThemePreferencesForRuntime,
|
||||
} from './theme-storage';
|
||||
|
||||
let createdWindow = false;
|
||||
let createdLocalStorage = false;
|
||||
|
||||
const ensureLocalStorage = (): void => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return;
|
||||
}
|
||||
const values = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
values.clear();
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
createdLocalStorage = true;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
createdWindow = true;
|
||||
}
|
||||
ensureLocalStorage();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (createdWindow) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
if (createdLocalStorage) {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
const preferences = {
|
||||
themeMode: 'dark' as const,
|
||||
lightThemeId: 'light-theme',
|
||||
darkThemeId: 'dark-theme',
|
||||
};
|
||||
|
||||
describe('theme preference runtime scoping', () => {
|
||||
test('keys differ per runtime', () => {
|
||||
expect(getThemePreferencesStorageKey('runtime-a')).not.toBe(getThemePreferencesStorageKey('runtime-b'));
|
||||
});
|
||||
|
||||
test('round-trips preferences for the same runtime', () => {
|
||||
writeThemePreferencesForRuntime('runtime-a', preferences);
|
||||
|
||||
expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
|
||||
});
|
||||
|
||||
test('a window on one instance never reads another instance theme', () => {
|
||||
writeThemePreferencesForRuntime('runtime-a', preferences);
|
||||
|
||||
expect(readThemePreferencesForRuntime('runtime-b')).toBeNull();
|
||||
});
|
||||
|
||||
test('latest write wins per runtime without cross-instance effects', () => {
|
||||
writeThemePreferencesForRuntime('runtime-a', preferences);
|
||||
writeThemePreferencesForRuntime('runtime-b', { themeMode: 'light', lightThemeId: 'other-light', darkThemeId: 'other-dark' });
|
||||
|
||||
expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
|
||||
expect(readThemePreferencesForRuntime('runtime-b')).toEqual({
|
||||
themeMode: 'light',
|
||||
lightThemeId: 'other-light',
|
||||
darkThemeId: 'other-dark',
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed or invalid payloads are failure, not empty authority', () => {
|
||||
localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json');
|
||||
expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
|
||||
|
||||
localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), JSON.stringify({ themeMode: 'neon' }));
|
||||
expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
|
||||
|
||||
localStorage.setItem(
|
||||
getThemePreferencesStorageKey('runtime-a'),
|
||||
JSON.stringify({ themeMode: 'dark', lightThemeId: '', darkThemeId: 'dark-theme' }),
|
||||
);
|
||||
expect(readThemePreferencesForRuntime('runtime-a')).toBeNull();
|
||||
});
|
||||
|
||||
test('leaves the splash-hint and migration-seed globals untouched', () => {
|
||||
localStorage.setItem('themeMode', 'dark');
|
||||
localStorage.setItem('lightThemeId', 'light-theme');
|
||||
localStorage.setItem('darkThemeId', 'dark-theme');
|
||||
localStorage.setItem('useSystemTheme', 'false');
|
||||
localStorage.setItem('selectedThemeId', 'dark-theme');
|
||||
localStorage.setItem('selectedThemeVariant', 'dark');
|
||||
localStorage.setItem('splashBgDark', '#0c0a09');
|
||||
localStorage.setItem('splashFgDark', '#fafaf9');
|
||||
|
||||
writeThemePreferencesForRuntime('runtime-a', preferences);
|
||||
|
||||
// The scoped key owns the app theme; the global keys stay as cosmetic
|
||||
// last-writer-wins hints for the pre-React splash shells and the Android
|
||||
// status bar, and as the one-time migration seed for new runtimes.
|
||||
expect(localStorage.getItem('themeMode')).toBe('dark');
|
||||
expect(localStorage.getItem('lightThemeId')).toBe('light-theme');
|
||||
expect(localStorage.getItem('darkThemeId')).toBe('dark-theme');
|
||||
expect(localStorage.getItem('useSystemTheme')).toBe('false');
|
||||
expect(localStorage.getItem('selectedThemeId')).toBe('dark-theme');
|
||||
expect(localStorage.getItem('selectedThemeVariant')).toBe('dark');
|
||||
expect(localStorage.getItem('splashBgDark')).toBe('#0c0a09');
|
||||
expect(localStorage.getItem('splashFgDark')).toBe('#fafaf9');
|
||||
expect(readThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme preference resolution chain', () => {
|
||||
test('uses the scoped entry when present', () => {
|
||||
writeThemePreferencesForRuntime('runtime-a', preferences);
|
||||
|
||||
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual(preferences);
|
||||
});
|
||||
|
||||
test('seeds from the legacy mode and theme ids when no scoped entry exists', () => {
|
||||
localStorage.setItem('themeMode', 'dark');
|
||||
localStorage.setItem('lightThemeId', 'legacy-light');
|
||||
localStorage.setItem('darkThemeId', 'legacy-dark');
|
||||
|
||||
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
|
||||
themeMode: 'dark',
|
||||
lightThemeId: 'legacy-light',
|
||||
darkThemeId: 'legacy-dark',
|
||||
});
|
||||
});
|
||||
|
||||
test('seeds from the useSystemTheme/selectedThemeId legacy chain', () => {
|
||||
localStorage.setItem('useSystemTheme', 'false');
|
||||
localStorage.setItem('selectedThemeId', DEFAULT_DARK_THEME_ID);
|
||||
|
||||
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
|
||||
themeMode: 'dark',
|
||||
lightThemeId: DEFAULT_LIGHT_THEME_ID,
|
||||
darkThemeId: DEFAULT_DARK_THEME_ID,
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to defaults when nothing is stored', () => {
|
||||
expect(resolveThemePreferencesForRuntime('runtime-a')).toEqual({
|
||||
themeMode: 'system',
|
||||
lightThemeId: DEFAULT_LIGHT_THEME_ID,
|
||||
darkThemeId: DEFAULT_DARK_THEME_ID,
|
||||
});
|
||||
});
|
||||
|
||||
test('the migrated seed survives into the scoped key while the seed globals stay', () => {
|
||||
localStorage.setItem('themeMode', 'dark');
|
||||
localStorage.setItem('lightThemeId', 'legacy-light');
|
||||
localStorage.setItem('darkThemeId', 'legacy-dark');
|
||||
|
||||
writeThemePreferencesForRuntime('runtime-a', resolveThemePreferencesForRuntime('runtime-a'));
|
||||
|
||||
expect(readThemePreferencesForRuntime('runtime-a')).toEqual({
|
||||
themeMode: 'dark',
|
||||
lightThemeId: 'legacy-light',
|
||||
darkThemeId: 'legacy-dark',
|
||||
});
|
||||
expect(localStorage.getItem('themeMode')).toBe('dark');
|
||||
expect(localStorage.getItem('lightThemeId')).toBe('legacy-light');
|
||||
expect(localStorage.getItem('darkThemeId')).toBe('legacy-dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime-switch adoption', () => {
|
||||
const current = { themeMode: 'dark' as const, lightThemeId: 'current-light', darkThemeId: 'current-dark' };
|
||||
|
||||
test('adopts the target runtime stored theme when one exists', () => {
|
||||
writeThemePreferencesForRuntime('runtime-b', preferences);
|
||||
|
||||
expect(adoptThemePreferencesForRuntime('runtime-b', current)).toEqual(preferences);
|
||||
});
|
||||
|
||||
test('keeps the current preferences — same reference — when the target runtime has no entry', () => {
|
||||
expect(adoptThemePreferencesForRuntime('runtime-empty', current)).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transient runtime keys', () => {
|
||||
test('uninitialized and disconnected runtime keys are transient', () => {
|
||||
expect(isTransientRuntimeKey('url:default')).toBe(true);
|
||||
expect(isTransientRuntimeKey('mobile-disconnected')).toBe(true);
|
||||
expect(isTransientRuntimeKey('')).toBe(true);
|
||||
expect(isTransientRuntimeKey('local')).toBe(false);
|
||||
expect(isTransientRuntimeKey('url:https://host.example')).toBe(false);
|
||||
});
|
||||
|
||||
test('writes are skipped for transient runtimes — no stale cold-boot theme gets pinned', () => {
|
||||
writeThemePreferencesForRuntime('url:default', preferences);
|
||||
writeThemePreferencesForRuntime('mobile-disconnected', preferences);
|
||||
|
||||
expect(readThemePreferencesForRuntime('url:default')).toBeNull();
|
||||
expect(readThemePreferencesForRuntime('mobile-disconnected')).toBeNull();
|
||||
expect(localStorage.getItem(getThemePreferencesStorageKey('url:default'))).toBeNull();
|
||||
});
|
||||
|
||||
test('reads never surface an entry under a transient key', () => {
|
||||
localStorage.setItem(getThemePreferencesStorageKey('url:default'), JSON.stringify(preferences));
|
||||
|
||||
expect(readThemePreferencesForRuntime('url:default')).toBeNull();
|
||||
});
|
||||
|
||||
test('boot resolution falls back to the global splash hints for transient runtimes', () => {
|
||||
localStorage.setItem('themeMode', 'light');
|
||||
localStorage.setItem('lightThemeId', 'legacy-light');
|
||||
localStorage.setItem('darkThemeId', 'legacy-dark');
|
||||
|
||||
expect(resolveThemePreferencesForRuntime('url:default')).toEqual({
|
||||
themeMode: 'light',
|
||||
lightThemeId: 'legacy-light',
|
||||
darkThemeId: 'legacy-dark',
|
||||
});
|
||||
});
|
||||
|
||||
test('endpoint-switch adoption keeps current preferences for transient runtimes', () => {
|
||||
const current = { themeMode: 'light' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' };
|
||||
|
||||
expect(adoptThemePreferencesForRuntime('mobile-disconnected', current)).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme storage event resolution', () => {
|
||||
const current = { themeMode: 'system' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' };
|
||||
|
||||
test('adopts a storage event for the current runtime', () => {
|
||||
writeThemePreferencesForRuntime('runtime-a', preferences);
|
||||
|
||||
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toEqual(preferences);
|
||||
});
|
||||
|
||||
test('ignores a storage event from another runtime', () => {
|
||||
writeThemePreferencesForRuntime('runtime-b', preferences);
|
||||
|
||||
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-b'), 'runtime-a', current)).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores legacy global theme keys (revert-to-globals regression guard)', () => {
|
||||
localStorage.setItem('themeMode', 'dark');
|
||||
localStorage.setItem('lightThemeId', 'light-theme');
|
||||
localStorage.setItem('darkThemeId', 'dark-theme');
|
||||
|
||||
expect(resolveThemePreferencesFromStorageEvent('themeMode', 'runtime-a', current)).toBeNull();
|
||||
expect(resolveThemePreferencesFromStorageEvent('lightThemeId', 'runtime-a', current)).toBeNull();
|
||||
expect(resolveThemePreferencesFromStorageEvent('darkThemeId', 'runtime-a', current)).toBeNull();
|
||||
});
|
||||
|
||||
test('resolves to no change when stored preferences already match', () => {
|
||||
writeThemePreferencesForRuntime('runtime-a', preferences);
|
||||
|
||||
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', preferences)).toBeNull();
|
||||
});
|
||||
|
||||
test('resolves to no change when nothing valid is stored', () => {
|
||||
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull();
|
||||
|
||||
localStorage.setItem(getThemePreferencesStorageKey('runtime-a'), 'not-json');
|
||||
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ThemeMode } from '@/types/theme';
|
||||
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes';
|
||||
|
||||
type StoredThemePreferences = {
|
||||
themeMode: ThemeMode;
|
||||
lightThemeId: string;
|
||||
darkThemeId: string;
|
||||
};
|
||||
|
||||
// Theme preferences are scoped per runtime endpoint, like the settings mirror
|
||||
// (lib/persistence.ts), so windows pointing at different instances never
|
||||
// overwrite or adopt each other's theme through shared localStorage.
|
||||
//
|
||||
// Retention is intentionally unbounded, unlike the mirror's capped 5-runtime
|
||||
// index: each entry is ~150 bytes, the count is bounded by the distinct
|
||||
// instances ever visited from this origin, and evicting old entries would only
|
||||
// discard the last-known theme for rarely visited instances while saving
|
||||
// trivial space.
|
||||
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']);
|
||||
|
||||
export const isTransientRuntimeKey = (runtimeKey: string): boolean =>
|
||||
TRANSIENT_RUNTIME_KEYS.has(runtimeKey);
|
||||
|
||||
export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => {
|
||||
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) {
|
||||
return null;
|
||||
}
|
||||
let raw: string | null = 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') {
|
||||
return null;
|
||||
}
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const lightThemeId = candidate.lightThemeId.trim();
|
||||
const darkThemeId = candidate.darkThemeId.trim();
|
||||
if (!lightThemeId || !darkThemeId) {
|
||||
return null;
|
||||
}
|
||||
return { themeMode: candidate.themeMode, lightThemeId, darkThemeId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => {
|
||||
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(getThemePreferencesStorageKey(runtimeKey), JSON.stringify(preferences));
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. read-only contextBridge) — the server
|
||||
// settings sync remains authoritative and the app still works.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the preferences a cross-window storage event should apply for the
|
||||
* current runtime. Returns null — meaning "keep current preferences" — when
|
||||
* the event targets another runtime's key, when no valid stored preferences
|
||||
* exist, or when the stored preferences already match the current ones (the
|
||||
* identity check breaks cross-window adoption loops).
|
||||
*/
|
||||
export const resolveThemePreferencesFromStorageEvent = (
|
||||
eventKey: string | null,
|
||||
runtimeKey: string,
|
||||
current: StoredThemePreferences,
|
||||
): StoredThemePreferences | null => {
|
||||
if (eventKey !== getThemePreferencesStorageKey(runtimeKey)) {
|
||||
return null;
|
||||
}
|
||||
const stored = readThemePreferencesForRuntime(runtimeKey);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
if (stored.themeMode === current.themeMode && stored.lightThemeId === current.lightThemeId && stored.darkThemeId === current.darkThemeId) {
|
||||
return null;
|
||||
}
|
||||
return stored;
|
||||
};
|
||||
|
||||
// One-time migration seed: pre-scoped builds persisted theme state in these
|
||||
// global keys. They are resolved only while no scoped entry exists — the
|
||||
// persist effect then seeds the scoped key from the returned preferences — so
|
||||
// no client-only theme state is discarded before the authoritative server sync
|
||||
// lands. The keys themselves stay (see ThemeSystemContext's persist effect):
|
||||
// the pre-React splash shells and the Android status bar read them as
|
||||
// cosmetic last-writer-wins hints.
|
||||
const readLegacyThemePreferences = (): StoredThemePreferences => {
|
||||
let themeMode: ThemeMode = 'system';
|
||||
let lightThemeId: string = DEFAULT_LIGHT_THEME_ID;
|
||||
let darkThemeId: string = DEFAULT_DARK_THEME_ID;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return { themeMode, lightThemeId, darkThemeId };
|
||||
}
|
||||
|
||||
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') {
|
||||
themeMode = legacyMode;
|
||||
} else if (legacyUseSystem !== null) {
|
||||
const useSystem = legacyUseSystem === 'true';
|
||||
if (useSystem) {
|
||||
themeMode = 'system';
|
||||
} else if (legacyThemeId) {
|
||||
const legacyTheme = getThemeById(legacyThemeId);
|
||||
if (legacyTheme) {
|
||||
themeMode = legacyTheme.metadata.variant === 'dark' ? 'dark' : 'light';
|
||||
if (legacyTheme.metadata.variant === 'dark') {
|
||||
darkThemeId = legacyTheme.metadata.id;
|
||||
} else {
|
||||
lightThemeId = legacyTheme.metadata.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (legacyVariant === 'light' || legacyVariant === 'dark') {
|
||||
themeMode = legacyVariant;
|
||||
}
|
||||
|
||||
const legacyLightId = localStorage.getItem('lightThemeId');
|
||||
const legacyDarkId = localStorage.getItem('darkThemeId');
|
||||
if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) {
|
||||
lightThemeId = legacyLightId.trim();
|
||||
}
|
||||
if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) {
|
||||
darkThemeId = legacyDarkId.trim();
|
||||
}
|
||||
|
||||
return { themeMode, lightThemeId, darkThemeId };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the preferences for a runtime at boot: the scoped entry when one
|
||||
* exists, otherwise a one-time seed from the superseded global keys, otherwise
|
||||
* defaults. The seed guarantees the first scoped write carries the last-known
|
||||
* theme instead of defaults.
|
||||
*/
|
||||
export const resolveThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences => {
|
||||
const stored = readThemePreferencesForRuntime(runtimeKey);
|
||||
return stored ?? readLegacyThemePreferences();
|
||||
};
|
||||
|
||||
/**
|
||||
* Adopt another runtime's stored preferences when the endpoint switches: the
|
||||
* new runtime's scoped entry when one exists, otherwise the current
|
||||
* preferences unchanged (the same reference — no re-render, no write-through)
|
||||
* until the incoming settings sync refines with the server's authoritative
|
||||
* value.
|
||||
*/
|
||||
export const adoptThemePreferencesForRuntime = (
|
||||
runtimeKey: string,
|
||||
current: StoredThemePreferences,
|
||||
): StoredThemePreferences => readThemePreferencesForRuntime(runtimeKey) ?? current;
|
||||
@@ -106,11 +106,6 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
}
|
||||
|
||||
persistRuntimeSettingsMirror(settings, getRuntimeKey());
|
||||
setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null);
|
||||
setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null);
|
||||
setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null);
|
||||
setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null);
|
||||
setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null);
|
||||
setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
|
||||
if (settings.homeDirectory) {
|
||||
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
||||
|
||||
Reference in New Issue
Block a user