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);
|
const retry = window.setTimeout(() => void applyStatusBar(), 400);
|
||||||
cleanup.push(() => window.clearTimeout(retry));
|
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 { App } = await import('@capacitor/app');
|
||||||
const stateHandle = await App.addListener('appStateChange', ({ isActive }) => {
|
const stateHandle = await App.addListener('appStateChange', ({ isActive }) => {
|
||||||
if (isActive) void applyStatusBar();
|
if (isActive) void applyStatusBar();
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ import {
|
|||||||
import { isValidTheme } from './theme-validation';
|
import { isValidTheme } from './theme-validation';
|
||||||
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
||||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||||
|
import {
|
||||||
|
adoptThemePreferencesForRuntime,
|
||||||
|
resolveThemePreferencesForRuntime,
|
||||||
|
resolveThemePreferencesFromStorageEvent,
|
||||||
|
writeThemePreferencesForRuntime,
|
||||||
|
} from './theme-storage';
|
||||||
|
|
||||||
type ThemePreferences = {
|
type ThemePreferences = {
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -87,46 +93,27 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
|||||||
const embeddedMode = embeddedParams?.get('themeMode');
|
const embeddedMode = embeddedParams?.get('themeMode');
|
||||||
const embeddedLightId = embeddedParams?.get('lightThemeId');
|
const embeddedLightId = embeddedParams?.get('lightThemeId');
|
||||||
const embeddedDarkId = embeddedParams?.get('darkThemeId');
|
const embeddedDarkId = embeddedParams?.get('darkThemeId');
|
||||||
const storedMode = localStorage.getItem('themeMode');
|
// Scoped entry when present; otherwise a one-time seed from the superseded
|
||||||
const storedLightId = localStorage.getItem('lightThemeId');
|
// global keys (see resolveThemePreferencesForRuntime), so the first scoped
|
||||||
const storedDarkId = localStorage.getItem('darkThemeId');
|
// write carries the last-known theme instead of defaults.
|
||||||
const legacyUseSystem = localStorage.getItem('useSystemTheme');
|
const resolvedPreferences = resolveThemePreferencesForRuntime(getRuntimeKey());
|
||||||
const legacyThemeId = localStorage.getItem('selectedThemeId');
|
|
||||||
const legacyVariant = localStorage.getItem('selectedThemeVariant');
|
|
||||||
|
|
||||||
if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') {
|
if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') {
|
||||||
themeMode = embeddedMode;
|
themeMode = embeddedMode;
|
||||||
} else if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') {
|
} else {
|
||||||
themeMode = storedMode;
|
themeMode = resolvedPreferences.themeMode;
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) {
|
if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) {
|
||||||
lightThemeId = embeddedLightId.trim();
|
lightThemeId = embeddedLightId.trim();
|
||||||
} else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) {
|
} else {
|
||||||
lightThemeId = storedLightId.trim();
|
lightThemeId = resolvedPreferences.lightThemeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) {
|
if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) {
|
||||||
darkThemeId = embeddedDarkId.trim();
|
darkThemeId = embeddedDarkId.trim();
|
||||||
} else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) {
|
} else {
|
||||||
darkThemeId = storedDarkId.trim();
|
darkThemeId = resolvedPreferences.darkThemeId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +301,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
customThemesRequestRef.current += 1;
|
customThemesRequestRef.current += 1;
|
||||||
setCustomThemes([]);
|
setCustomThemes([]);
|
||||||
setCustomThemesLoading(false);
|
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();
|
void reloadCustomThemes();
|
||||||
}), [isVSCode, reloadCustomThemes]);
|
}), [isVSCode, reloadCustomThemes]);
|
||||||
|
|
||||||
@@ -424,6 +414,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
return;
|
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('themeMode', preferences.themeMode);
|
||||||
localStorage.setItem('lightThemeId', preferences.lightThemeId);
|
localStorage.setItem('lightThemeId', preferences.lightThemeId);
|
||||||
localStorage.setItem('darkThemeId', preferences.darkThemeId);
|
localStorage.setItem('darkThemeId', preferences.darkThemeId);
|
||||||
@@ -434,8 +435,6 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
|
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 lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
|
||||||
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
||||||
|
|
||||||
@@ -459,37 +458,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key !== 'themeMode' && event.key !== 'lightThemeId' && event.key !== 'darkThemeId') {
|
setPreferences((prev) => resolveThemePreferencesFromStorageEvent(event.key, getRuntimeKey(), prev) ?? prev);
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorage);
|
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());
|
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);
|
setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
|
||||||
if (settings.homeDirectory) {
|
if (settings.homeDirectory) {
|
||||||
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
||||||
|
|||||||
Reference in New Issue
Block a user