refactor(lib,playback): fold utils/cache+themes into lib; playbackScheduleFormat→playback (drains utils/format)

This commit is contained in:
Psychotoxical
2026-06-30 20:33:25 +02:00
parent 31d4a87401
commit 6becd57798
47 changed files with 69 additions and 69 deletions
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reconcileEphemeralCache } from '@/lib/cache/ephemeralTierReconcile';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}));
import { invoke } from '@tauri-apps/api/core';
describe('reconcileEphemeralCache', () => {
beforeEach(() => {
vi.mocked(invoke).mockReset();
useLocalPlaybackStore.setState({ entries: {} });
});
it('drops stale index rows and prunes empty dirs without deleting unindexed files', async () => {
useLocalPlaybackStore.setState({
entries: {
'srv:gone': {
serverIndexKey: 'srv',
trackId: 'gone',
localPath: '/media/cache/srv/gone.flac',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'ephemeral',
cachedAt: 1,
suffix: 'flac',
},
'srv:keep': {
serverIndexKey: 'srv',
trackId: 'keep',
localPath: '/media/cache/srv/keep.flac',
layoutFingerprint: '',
sizeBytes: 2,
tier: 'ephemeral',
cachedAt: 2,
suffix: 'flac',
},
},
});
vi.mocked(invoke).mockImplementation(async (cmd, args) => {
if (cmd === 'probe_media_files') {
const paths = (args as { localPaths: string[] }).localPaths;
return paths.map(p => p.endsWith('keep.flac'));
}
if (cmd === 'prune_empty_media_tier_dirs') return undefined;
throw new Error(`unexpected invoke ${cmd}`);
});
const result = await reconcileEphemeralCache();
expect(result).toEqual({ removedStaleIndex: 1 });
expect(useLocalPlaybackStore.getState().entries['srv:keep']).toBeDefined();
expect(useLocalPlaybackStore.getState().entries['srv:gone']).toBeUndefined();
expect(invoke).not.toHaveBeenCalledWith(
'prune_orphan_ephemeral_cache_files',
expect.anything(),
);
expect(invoke).toHaveBeenCalledWith('prune_empty_media_tier_dirs', {
tier: 'ephemeral',
mediaDir: null,
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { invoke } from '@tauri-apps/api/core';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { parseLocalPlaybackEntryKey } from '@/store/localPlaybackKeys';
import { getMediaDir } from '@/lib/media/mediaDir';
export interface EphemeralReconcileResult {
removedStaleIndex: number;
}
/** On-disk byte total under `{media}/cache/` (all instances sharing the media dir). */
export async function getEphemeralDiskBytes(mediaDir: string | null): Promise<number> {
return invoke<number>('get_media_tier_size', { tier: 'ephemeral', mediaDir }).catch(() => 0);
}
/**
* Delete cache files not in `keepPaths`, oldest mtime first, until tier size ≤ `maxBytes`.
* Used when dev/prod share one media dir and another instance's bytes are not in this index.
*/
export async function evictEphemeralOrphansToFit(
maxBytes: number,
mediaDir: string | null,
keepPaths: string[],
): Promise<string[]> {
return invoke<string[]>('evict_ephemeral_cache_orphans_to_fit', {
keepPaths,
maxBytes,
mediaDir,
}).catch(() => []);
}
/**
* Index↔disk sync without evicting unindexed files (safe when dev + prod share `media/cache/`):
* - drop index rows whose files are gone
* - prune empty directories under `{media}/cache/`
*
* Unindexed on-disk files are removed only from `evictEphemeralToFit` when over budget.
*/
export async function reconcileEphemeralCache(): Promise<EphemeralReconcileResult> {
const lp = useLocalPlaybackStore.getState();
const mediaDir = getMediaDir();
const ephemeral = Object.entries(lp.entries).filter(([, e]) => e.tier === 'ephemeral');
const paths = ephemeral.map(([, e]) => e.localPath);
const existsFlags =
paths.length > 0
? await invoke<boolean[]>('probe_media_files', { localPaths: paths }).catch(() =>
paths.map(() => false),
)
: [];
let removedStaleIndex = 0;
ephemeral.forEach(([key, _entry], i) => {
if (existsFlags[i]) {
return;
}
const parsed = parseLocalPlaybackEntryKey(key);
if (parsed) {
lp.removeEntry(parsed.trackId, parsed.serverIndexKey, 'reconcile-missing-bytes');
removedStaleIndex += 1;
}
});
await invoke('prune_empty_media_tier_dirs', { tier: 'ephemeral', mediaDir }).catch(() => {});
return { removedStaleIndex };
}
+38
View File
@@ -0,0 +1,38 @@
/** When true, hot-cache prefetch must not start new downloads (playback has priority). */
let deferHotCachePrefetch = false;
/** Last track that was «current» before a forward queue step — not evicted until debounce elapses. */
let previousTrackGraceUntilMs = 0;
let previousTrackGraceTrackId: string | null = null;
let previousTrackGraceServerId: string | null = null;
export function setDeferHotCachePrefetch(v: boolean): void {
deferHotCachePrefetch = v;
}
export function getDeferHotCachePrefetch(): boolean {
return deferHotCachePrefetch;
}
/** Call when `queueIndex` advances; the old current track stays eviction-safe for `debounceSec` (capped at 600 s). */
export function bumpHotCachePreviousTrackGrace(
trackId: string,
serverId: string,
debounceSec: number,
): void {
const sec = Number.isFinite(debounceSec) ? Math.min(600, Math.max(0, debounceSec)) : 0;
previousTrackGraceUntilMs = Date.now() + sec * 1000;
previousTrackGraceTrackId = trackId;
previousTrackGraceServerId = serverId;
}
export function isHotCachePreviousTrackUnderGrace(trackId: string, serverId: string): boolean {
if (!previousTrackGraceTrackId || Date.now() >= previousTrackGraceUntilMs) return false;
return previousTrackGraceTrackId === trackId && previousTrackGraceServerId === serverId;
}
export function clearHotCachePreviousGrace(): void {
previousTrackGraceUntilMs = 0;
previousTrackGraceTrackId = null;
previousTrackGraceServerId = null;
}
+20
View File
@@ -0,0 +1,20 @@
// Module-level TTL caches (shared across mounts).
// Used by NowPlaying subcomponents to avoid hammering Subsonic / Last.fm /
// Bandsintown on every track / artist change.
export const CACHE_TTL_MS = 5 * 60 * 1000;
interface CacheEntry<T> { value: T; ts: number; }
export function makeCache<T>() {
const map = new Map<string, CacheEntry<T>>();
return {
get(key: string): T | undefined {
const e = map.get(key);
if (!e) return undefined;
if (Date.now() - e.ts > CACHE_TTL_MS) { map.delete(key); return undefined; }
return e.value;
},
set(key: string, value: T) { map.set(key, { value, ts: Date.now() }); },
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The fixed, bundled core themes — always present, never uninstallable. Every
* other palette lives in the community Theme Store and is applied by id once
* installed. `bg`/`card`/`accent` drive the 3-band swatch preview.
*/
export interface FixedTheme {
id: string;
label: string;
bg: string;
card: string;
accent: string;
/** Colour-blind-safe accessibility theme — flagged with a badge in the UI. */
accessibility?: boolean;
}
export const FIXED_THEMES: FixedTheme[] = [
{ id: 'mocha', label: 'Catppuccin Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'latte', label: 'Catppuccin Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
{ id: 'kanagawa-wave', label: 'Kanagawa Wave', bg: '#1F1F28', card: '#2A2A37', accent: '#7E9CD8' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700', accessibility: true },
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700', accessibility: true },
];
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/themes/themeRegistry', () => ({ fetchThemeCss: vi.fn() }));
vi.mock('@/lib/themes/themeInjection', () => ({ validateThemeCss: vi.fn() }));
import { fetchThemeCss, type RegistryTheme } from '@/lib/themes/themeRegistry';
import { validateThemeCss } from '@/lib/themes/themeInjection';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import { installThemeFromRegistry } from '@/lib/themes/installThemeFromRegistry';
const fetchCss = vi.mocked(fetchThemeCss);
const validate = vi.mocked(validateThemeCss);
const TH = {
id: 'theme-a',
name: 'Theme A',
author: 'someone',
version: '1.1.0',
description: 'desc',
mode: 'dark',
tags: ['x'],
css: 'themes/theme-a/theme.css',
} as unknown as RegistryTheme;
beforeEach(() => {
useInstalledThemesStore.setState({ themes: [] });
fetchCss.mockReset();
validate.mockReset();
});
describe('installThemeFromRegistry', () => {
it('installs the validated CSS and returns ok', async () => {
fetchCss.mockResolvedValue('/* css */');
validate.mockReturnValue('/* css */');
await expect(installThemeFromRegistry(TH)).resolves.toBe('ok');
const installed = useInstalledThemesStore.getState().getInstalled('theme-a');
expect(installed?.version).toBe('1.1.0');
expect(installed?.css).toBe('/* css */');
});
it('does not persist CSS that fails the safety floor', async () => {
fetchCss.mockResolvedValue('bad');
validate.mockReturnValue(null);
await expect(installThemeFromRegistry(TH)).resolves.toBe('invalid');
expect(useInstalledThemesStore.getState().isInstalled('theme-a')).toBe(false);
});
it('returns error when the fetch fails', async () => {
fetchCss.mockRejectedValue(new Error('network'));
await expect(installThemeFromRegistry(TH)).resolves.toBe('error');
expect(useInstalledThemesStore.getState().isInstalled('theme-a')).toBe(false);
});
});
@@ -0,0 +1,37 @@
import { fetchThemeCss, type RegistryTheme } from '@/lib/themes/themeRegistry';
import { validateThemeCss } from '@/lib/themes/themeInjection';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
export type InstallResult = 'ok' | 'invalid' | 'error';
/**
* Fetch a registry theme's CSS, validate it against the in-app safety floor,
* and persist it (install or in-place update — the store replaces by id).
* Shared by the Theme Store list and the "your themes" update chip so both go
* through the same fetch → validate → install path.
*
* Never throws: returns `'invalid'` when the CSS fails the floor and `'error'`
* on a network/fetch failure, so callers can surface it without a try/catch.
*/
export async function installThemeFromRegistry(th: RegistryTheme): Promise<InstallResult> {
try {
const css = await fetchThemeCss(th.css);
// Don't persist CSS that won't inject — it would show as installed/active
// but render nothing. Validate before storing.
if (validateThemeCss(css, th.id) == null) return 'invalid';
useInstalledThemesStore.getState().install({
id: th.id,
name: th.name,
author: th.author,
version: th.version,
description: th.description,
mode: th.mode,
tags: th.tags,
css,
installedAt: Date.now(),
});
return 'ok';
} catch {
return 'error';
}
}
@@ -0,0 +1,89 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BUILTIN_SPLASH_PALETTES } from '@/config/startupSplashPalettes';
import {
applyStartupSplashPalette,
resolveEffectiveThemeId,
resolveScheduledThemeId,
resolveSplashPalette,
} from '@/lib/themes/startupThemeAppearance';
describe('startupThemeAppearance', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
});
afterEach(() => {
localStorage.clear();
});
it('resolves scheduled day/night theme', () => {
const theme = resolveScheduledThemeId({
enableThemeScheduler: true,
theme: 'mocha',
themeDay: 'latte',
themeNight: 'stark-hud',
timeDayStart: '00:00',
timeNightStart: '12:00',
});
expect(['latte', 'stark-hud']).toContain(theme);
});
it('reads effective theme from persisted store', () => {
localStorage.setItem('psysonic_theme', JSON.stringify({
state: {
theme: 'vision-dark',
enableThemeScheduler: false,
},
}));
expect(resolveEffectiveThemeId()).toBe('vision-dark');
});
it('parses community theme css into splash palette', () => {
const palette = resolveSplashPalette('my-theme', [{
id: 'my-theme',
css: `[data-theme='my-theme'] { --bg-app: #112233; --accent: #aabbcc; --text-primary: #ddeeff; --text-muted: #99aabb; --bg-card: #223344; }`,
}]);
expect(palette).toMatchObject({
bg: '#112233',
accent: '#aabbcc',
text: '#ddeeff',
muted: '#99aabb',
track: '#223344',
logoStart: '#aabbcc',
logoEnd: '#aabbcc',
});
});
it('applies palette css variables on document root', () => {
applyStartupSplashPalette('kanagawa-wave', BUILTIN_SPLASH_PALETTES['kanagawa-wave']);
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
expect(document.documentElement.style.getPropertyValue('--startup-splash-bg').trim()).toBe('#1F1F28');
expect(document.documentElement.style.getPropertyValue('--startup-splash-accent').trim()).toBe('#7E9CD8');
expect(document.documentElement.style.getPropertyValue('--startup-splash-logo-start').trim()).toBe('#7E9CD8');
expect(document.documentElement.style.getPropertyValue('--startup-splash-logo-end').trim()).toBe('#957FB8');
});
it('reads custom logo gradient from community theme css', () => {
const palette = resolveSplashPalette('brand', [{
id: 'brand',
css: `[data-theme='brand'] { --bg-app: #111; --accent: #f00; --logo-color-start: #0f0; --logo-color-end: #00f; }`,
}]);
expect(palette.logoStart).toBe('#0f0');
expect(palette.logoEnd).toBe('#00f');
});
it('keeps public preflight palettes in sync with bundled theme map', () => {
const preflight = readFileSync(
resolve(process.cwd(), 'public/startup-splash-preflight.js'),
'utf8',
);
for (const themeId of Object.keys(BUILTIN_SPLASH_PALETTES)) {
expect(preflight).toContain(`'${themeId}'`);
expect(preflight).toContain(BUILTIN_SPLASH_PALETTES[themeId].bg);
expect(preflight).toContain(BUILTIN_SPLASH_PALETTES[themeId].logoStart);
}
});
});
+145
View File
@@ -0,0 +1,145 @@
import {
BUILTIN_SPLASH_PALETTES,
BUILTIN_THEME_IDS,
type StartupSplashPalette,
} from '@/config/startupSplashPalettes';
export type PersistedThemeState = {
enableThemeScheduler: boolean;
theme: string;
themeDay: string;
themeNight: string;
timeDayStart: string;
timeNightStart: string;
};
export type InstalledThemeSnapshot = {
id: string;
css: string;
};
const THEME_STORAGE_KEY = 'psysonic_theme';
const INSTALLED_THEMES_STORAGE_KEY = 'psysonic_installed_themes';
export function resolveScheduledThemeId(state: PersistedThemeState): string {
if (!state.enableThemeScheduler) return state.theme;
const now = new Date();
const nowMins = now.getHours() * 60 + now.getMinutes();
const [dh, dm] = state.timeDayStart.split(':').map(Number);
const [nh, nm] = state.timeNightStart.split(':').map(Number);
const dayMins = dh * 60 + dm;
const nightMins = nh * 60 + nm;
const isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
}
export function readPersistedThemeState(): PersistedThemeState | null {
try {
const raw = localStorage.getItem(THEME_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
const s = parsed.state;
if (!s) return null;
return {
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme ?? 'mocha'),
themeDay: String(s.themeDay ?? 'latte'),
themeNight: String(s.themeNight ?? 'mocha'),
timeDayStart: String(s.timeDayStart ?? '07:00'),
timeNightStart: String(s.timeNightStart ?? '19:00'),
};
} catch {
return null;
}
}
export function readInstalledThemesFromStorage(): InstalledThemeSnapshot[] {
try {
const raw = localStorage.getItem(INSTALLED_THEMES_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledThemeSnapshot[] } };
const themes = parsed.state?.themes;
return Array.isArray(themes) ? themes : [];
} catch {
return [];
}
}
export function resolveEffectiveThemeId(): string {
const persisted = readPersistedThemeState();
if (!persisted) return 'mocha';
return resolveScheduledThemeId(persisted);
}
function readCssVar(css: string, name: string): string | null {
const match = css.match(new RegExp(`${name}\\s*:\\s*([^;]+);`));
const value = match?.[1]?.trim();
return value && value.length > 0 ? value : null;
}
function resolveLogoColors(
css: string,
accent: string,
): Pick<StartupSplashPalette, 'logoStart' | 'logoEnd'> {
const logoStart = readCssVar(css, '--logo-color-start') ?? accent;
const logoEnd = readCssVar(css, '--logo-color-end')
?? readCssVar(css, '--accent-2')
?? accent;
return { logoStart, logoEnd };
}
function paletteFromCommunityCss(css: string, fallbackTrack: string): StartupSplashPalette | null {
const bg = readCssVar(css, '--bg-app');
const accent = readCssVar(css, '--accent');
if (!bg || !accent) return null;
const text = readCssVar(css, '--text-primary') ?? readCssVar(css, '--ctp-text') ?? '#cdd6f4';
const muted = readCssVar(css, '--text-muted') ?? readCssVar(css, '--ctp-subtext0') ?? text;
const track = readCssVar(css, '--bg-card') ?? readCssVar(css, '--border-subtle') ?? fallbackTrack;
return { bg, text, muted, accent, track, ...resolveLogoColors(css, accent) };
}
export function resolveSplashPalette(
themeId: string,
installedThemes: InstalledThemeSnapshot[] = readInstalledThemesFromStorage(),
): StartupSplashPalette {
const builtin = BUILTIN_SPLASH_PALETTES[themeId];
if (builtin) return builtin;
const installed = installedThemes.find(theme => theme.id === themeId);
if (installed) {
const fromCss = paletteFromCommunityCss(installed.css, BUILTIN_SPLASH_PALETTES.mocha.track);
if (fromCss) return fromCss;
}
return BUILTIN_SPLASH_PALETTES.mocha;
}
export function applyStartupSplashPalette(
themeId: string,
palette: StartupSplashPalette,
): void {
const root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.style.setProperty('--startup-splash-bg', palette.bg);
root.style.setProperty('--startup-splash-text', palette.text);
root.style.setProperty('--startup-splash-muted', palette.muted);
root.style.setProperty('--startup-splash-accent', palette.accent);
root.style.setProperty('--startup-splash-track', palette.track);
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
root.style.background = palette.bg;
document.body.style.background = palette.bg;
}
export function applyStartupSplashThemeFromStorage(): string {
const themeId = resolveEffectiveThemeId();
const palette = resolveSplashPalette(themeId);
applyStartupSplashPalette(themeId, palette);
return themeId;
}
export function isBuiltinThemeId(themeId: string): boolean {
return BUILTIN_THEME_IDS.includes(themeId);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Tests for the runtime theme-CSS security floor and <head> injection sync.
* Community themes are free-form; the floor only blocks the hard safety
* invariants (network, scripts, breakout, unscoped @keyframes, size).
*/
import { afterEach, describe, expect, it } from 'vitest';
import {
validateThemeCss,
injectTheme,
syncInjectedThemes,
} from '@/lib/themes/themeInjection';
import type { InstalledTheme } from '@/store/installedThemesStore';
const ATTR = 'data-installed-theme';
function mk(id: string, css: string): InstalledTheme {
return { id, name: id, author: 'a', version: '1.0.0', description: '', mode: 'dark', css, installedAt: 0 };
}
const block = (id: string, body = '--accent:#fff;') => `[data-theme='${id}']{ ${body} }`;
const injected = () => document.head.querySelectorAll(`style[${ATTR}]`);
afterEach(() => {
document.head.querySelectorAll(`style[${ATTR}]`).forEach((el) => el.remove());
});
describe('validateThemeCss (security floor)', () => {
it('accepts a simple scoped rule', () => {
expect(validateThemeCss(block('dracula'), 'dracula')).not.toBeNull();
});
it('accepts free-form selectors and structure', () => {
const css = `html { color: red; } .sidebar { background: #000; } [data-theme='x'] .player-bar { opacity: 0.9; }`;
expect(validateThemeCss(css, 'x')).not.toBeNull();
});
it('accepts @media and multiple rules', () => {
const css = `${block('x')} @media (min-width: 600px) { .sidebar { width: 200px; } }`;
expect(validateThemeCss(css, 'x')).not.toBeNull();
});
it('accepts @keyframes namespaced with the theme id, and its animation use', () => {
const css = `@keyframes x-pulse { from { opacity: 1 } to { opacity: 0.5 } } .sidebar { animation: x-pulse 2s infinite; }`;
expect(validateThemeCss(css, 'x')).not.toBeNull();
});
it('accepts a data: url()', () => {
const css = block('x', `--select-arrow: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E");`);
expect(validateThemeCss(css, 'x')).not.toBeNull();
});
it('rejects @keyframes not namespaced with the theme id', () => {
expect(validateThemeCss(`@keyframes pulse { from {} to {} } ${block('x')}`, 'x')).toBeNull();
});
it('rejects @import', () => {
expect(validateThemeCss(`@import 'evil.css'; ${block('x')}`, 'x')).toBeNull();
});
it('rejects @property (global custom-prop registration)', () => {
const css = `@property --x { syntax: '<color>'; inherits: false; initial-value: red; } ${block('x')}`;
expect(validateThemeCss(css, 'x')).toBeNull();
});
it('rejects a non-data url()', () => {
expect(validateThemeCss(block('x', `--accent: url(https://evil.test/x.png);`), 'x')).toBeNull();
});
it('rejects </style> / <script> breakout', () => {
expect(validateThemeCss(`${block('x')}</style><script>`, 'x')).toBeNull();
});
it('rejects expression() / javascript:', () => {
expect(validateThemeCss(block('x', '--accent: expression(alert(1));'), 'x')).toBeNull();
expect(validateThemeCss(block('x', '--accent: javascript:alert(1);'), 'x')).toBeNull();
});
it('rejects an oversized css blob', () => {
const huge = `[data-theme='x']{ ${'--accent:#ffffff;'.repeat(20000)} }`;
expect(huge.length).toBeGreaterThan(256 * 1024);
expect(validateThemeCss(huge, 'x')).toBeNull();
});
it('ignores comments when validating', () => {
expect(validateThemeCss(`/* hi */ ${block('x')}`, 'x')).not.toBeNull();
// A comment cannot smuggle an @import past the floor.
expect(validateThemeCss(`${block('x')} /* */ @import 'x';`, 'x')).toBeNull();
});
});
describe('syncInjectedThemes', () => {
it('injects one <style> per installed theme', () => {
syncInjectedThemes([mk('a', block('a')), mk('b', block('b'))]);
expect(injected()).toHaveLength(2);
expect(document.head.querySelector(`style[${ATTR}="a"]`)?.textContent).toContain('data-theme');
});
it('removes styles for themes no longer installed', () => {
syncInjectedThemes([mk('a', block('a')), mk('b', block('b'))]);
syncInjectedThemes([mk('a', block('a'))]);
expect(injected()).toHaveLength(1);
expect(document.head.querySelector(`style[${ATTR}="b"]`)).toBeNull();
});
it('is idempotent (no duplicate elements)', () => {
syncInjectedThemes([mk('a', block('a'))]);
syncInjectedThemes([mk('a', block('a'))]);
expect(injected()).toHaveLength(1);
});
it('updates textContent when the css changes', () => {
injectTheme(mk('a', block('a', '--accent:#111;')));
injectTheme(mk('a', block('a', '--accent:#222;')));
const el = document.head.querySelector(`style[${ATTR}="a"]`);
expect(injected()).toHaveLength(1);
expect(el?.textContent).toContain('#222');
});
it('does not inject css that fails the floor', () => {
syncInjectedThemes([mk('a', `@import 'evil.css'; ${block('a')}`)]);
expect(injected()).toHaveLength(0);
});
});
+99
View File
@@ -0,0 +1,99 @@
import type { InstalledTheme } from '@/store/installedThemesStore';
/**
* Runtime CSS injection for installed community themes. Built-in themes are
* bundled at build time (`src/styles/themes/index.css`); installed ones have no
* build-time presence, so their CSS is injected into <head> at runtime. Each
* installed theme gets one `<style data-installed-theme="<id>">` element, kept
* in sync with the store.
*/
const ATTR = 'data-installed-theme';
// Generous but bounded — a rich free-form theme (animations, an embedded
// data: font/icon) is still small; this matches the import command's CSS cap
// and keeps one theme from eating the install store's localStorage quota.
const MAX_CSS_BYTES = 256 * 1024;
/**
* The in-app **security floor** for an installed theme's CSS. Community themes
* are free-form (any selectors, structure, `@keyframes`, animations — quality
* is handled by store moderation, and sideloaded themes are installed at the
* user's own risk). This guard enforces only the hard safety invariants the app
* relies on, because every installed theme is injected into <head> at all times:
*
* - can't break out of its `<style>` element (`</style>` / `<script>`),
* - can't pull anything off the network — no `@import`, and `url()` may only be
* an inline `data:` URI (prevents tracking/exfiltration on every app start),
* - no `@property` (would register global custom properties that could clash
* with the app or other themes),
* - no legacy script-in-CSS vectors (`expression()`, `javascript:`,
* `-moz-binding`),
* - a size cap, and
* - `@keyframes` must be namespaced with the theme id (`<id>-…`) so animations
* from different installed themes can't collide.
*
* Returns the original CSS if it passes, or `null` if it must not be injected.
*/
export function validateThemeCss(css: string, id: string): string | null {
if (typeof css !== 'string' || !css) return null;
if (css.length > MAX_CSS_BYTES) return null;
// Strip comments first so they can't smuggle content past the checks.
const s = css.replace(/\/\*[\s\S]*?\*\//g, '');
// No element breakout, no remote stylesheet, no global custom-prop registration.
if (/<\/?\s*(?:style|script)\b/i.test(s)) return null;
if (/@import\b/i.test(s)) return null;
if (/@(?:-[a-z]+-)?property\b/i.test(s)) return null;
// No legacy script-in-CSS vectors.
if (/expression\s*\(/i.test(s) || /javascript:/i.test(s) || /-moz-binding/i.test(s)) return null;
// url() may only be an inline data: URI. Match each `url(` and inspect the
// start of its content — a non-data target never starts with `data:`.
const urls = s.match(/url\(\s*['"]?\s*[^'")]*/gi) || [];
for (const u of urls) {
const inner = u.replace(/^url\(\s*['"]?\s*/i, '');
if (!/^data:/i.test(inner)) return null;
}
// @keyframes (and vendor-prefixed) must start with `<id>-`.
const prefix = `${id}-`;
const kf = s.matchAll(/@(?:-[a-z]+-)?keyframes\s+([A-Za-z0-9_-]+)/gi);
for (const m of kf) {
if (!m[1].startsWith(prefix)) return null;
}
return css;
}
export function injectTheme(theme: InstalledTheme): void {
const clean = validateThemeCss(theme.css, theme.id);
if (clean == null) return;
const selector = `style[${ATTR}="${CSS.escape(theme.id)}"]`;
let el = document.head.querySelector<HTMLStyleElement>(selector);
if (!el) {
el = document.createElement('style');
el.setAttribute(ATTR, theme.id);
document.head.appendChild(el);
}
if (el.textContent !== clean) el.textContent = clean;
}
export function removeInjectedTheme(id: string): void {
document.head.querySelector(`style[${ATTR}="${CSS.escape(id)}"]`)?.remove();
}
/**
* Reconcile the injected <style> elements with the given installed set: drop
* styles for themes no longer installed, add/update the rest. Idempotent —
* safe to call on every change and at startup.
*/
export function syncInjectedThemes(themes: InstalledTheme[]): void {
const wanted = new Set(themes.map((t) => t.id));
document.head
.querySelectorAll<HTMLStyleElement>(`style[${ATTR}]`)
.forEach((el) => {
const id = el.getAttribute(ATTR);
if (id && !wanted.has(id)) el.remove();
});
for (const theme of themes) injectTheme(theme);
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Tests for the slim-bundle theme migration (C5).
*
* The migration rewrites the persisted theme selection in localStorage before
* React mounts: any theme/themeDay/themeNight that is neither bundled
* (FIXED_THEMES) nor installed is reset to a bundled fallback — Mocha for the
* main + night slots, Latte for the day slot. It never installs anything and
* must never throw on malformed/missing storage.
*/
import { afterEach, describe, expect, it } from 'vitest';
import {
migrateThemeSelection,
readThemeMigrationNotice,
clearThemeMigrationNotice,
} from '@/lib/themes/themeMigration';
import { FIXED_THEMES } from '@/lib/themes/fixedThemes';
const THEME_KEY = 'psysonic_theme';
const INSTALLED_KEY = 'psysonic_installed_themes';
interface ThemeSlots {
theme?: unknown;
themeDay?: unknown;
themeNight?: unknown;
[k: string]: unknown;
}
function setPersistedTheme(state: ThemeSlots, version = 1): void {
localStorage.setItem(THEME_KEY, JSON.stringify({ state, version }));
}
function readPersistedTheme(): { state: ThemeSlots; version: number } {
return JSON.parse(localStorage.getItem(THEME_KEY) as string);
}
function setInstalled(ids: string[]): void {
localStorage.setItem(
INSTALLED_KEY,
JSON.stringify({ state: { themes: ids.map((id) => ({ id })) }, version: 1 }),
);
}
afterEach(() => {
localStorage.clear();
});
describe('migrateThemeSelection', () => {
it('does nothing when there is no persisted theme (fresh profile)', () => {
migrateThemeSelection();
expect(localStorage.getItem(THEME_KEY)).toBeNull();
});
it('leaves bundled (fixed) themes untouched', () => {
setPersistedTheme({ theme: 'kanagawa-wave', themeDay: 'latte', themeNight: 'mocha' });
migrateThemeSelection();
expect(readPersistedTheme().state).toMatchObject({
theme: 'kanagawa-wave',
themeDay: 'latte',
themeNight: 'mocha',
});
});
it('every fixed theme id resolves (none is treated as unresolved)', () => {
const ids = FIXED_THEMES.map((t) => t.id);
setPersistedTheme({ theme: ids[0], themeDay: ids[1], themeNight: ids[2] });
migrateThemeSelection();
expect(readPersistedTheme().state).toMatchObject({
theme: ids[0],
themeDay: ids[1],
themeNight: ids[2],
});
});
it('resets an unresolved main theme to Mocha (dark)', () => {
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
migrateThemeSelection();
expect(readPersistedTheme().state.theme).toBe('mocha');
});
it('resets an unresolved day theme to Latte (light)', () => {
setPersistedTheme({ theme: 'mocha', themeDay: 'nord-snowstorm', themeNight: 'mocha' });
migrateThemeSelection();
expect(readPersistedTheme().state.themeDay).toBe('latte');
});
it('resets an unresolved night theme to Mocha (dark)', () => {
setPersistedTheme({ theme: 'mocha', themeDay: 'latte', themeNight: 'blade' });
migrateThemeSelection();
expect(readPersistedTheme().state.themeNight).toBe('mocha');
});
it('keeps a store theme that the user has installed', () => {
setInstalled(['dracula']);
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
migrateThemeSelection();
expect(readPersistedTheme().state.theme).toBe('dracula');
});
it('migrates only the unresolved slots and preserves the rest', () => {
setInstalled(['gruvbox-dark-hard']);
setPersistedTheme({
theme: 'nord', // unresolved -> mocha
themeDay: 'latte', // fixed -> unchanged
themeNight: 'gruvbox-dark-hard', // installed -> unchanged
timeDayStart: '07:00',
enableThemeScheduler: true,
});
migrateThemeSelection();
const { state, version } = readPersistedTheme();
expect(state).toMatchObject({
theme: 'mocha',
themeDay: 'latte',
themeNight: 'gruvbox-dark-hard',
timeDayStart: '07:00',
enableThemeScheduler: true,
});
expect(version).toBe(1);
});
it('does not rewrite storage when nothing changes', () => {
setPersistedTheme({ theme: 'mocha', themeDay: 'latte', themeNight: 'mocha' });
const before = localStorage.getItem(THEME_KEY);
migrateThemeSelection();
expect(localStorage.getItem(THEME_KEY)).toBe(before);
});
it('still falls back when the installed-themes store is malformed', () => {
localStorage.setItem(INSTALLED_KEY, '{not valid json');
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
migrateThemeSelection();
expect(readPersistedTheme().state.theme).toBe('mocha');
});
it('does not throw on malformed theme storage', () => {
localStorage.setItem(THEME_KEY, '{not valid json');
expect(() => migrateThemeSelection()).not.toThrow();
expect(localStorage.getItem(THEME_KEY)).toBe('{not valid json');
});
it('ignores empty-string slot values', () => {
setPersistedTheme({ theme: '', themeDay: 'latte', themeNight: 'mocha' });
migrateThemeSelection();
// empty string is not a real selection — left as-is, store applies its default
expect(readPersistedTheme().state.theme).toBe('');
});
});
describe('theme migration notice', () => {
it('records the original id(s) that were reset', () => {
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'nord' });
migrateThemeSelection();
expect(readThemeMigrationNotice().sort()).toEqual(['dracula', 'nord']);
});
it('deduplicates ids used in multiple slots', () => {
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'dracula' });
migrateThemeSelection();
expect(readThemeMigrationNotice()).toEqual(['dracula']);
});
it('writes no notice when nothing was reset', () => {
setPersistedTheme({ theme: 'mocha', themeDay: 'latte', themeNight: 'mocha' });
migrateThemeSelection();
expect(readThemeMigrationNotice()).toEqual([]);
});
it('clear removes the notice', () => {
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
migrateThemeSelection();
expect(readThemeMigrationNotice()).toEqual(['dracula']);
clearThemeMigrationNotice();
expect(readThemeMigrationNotice()).toEqual([]);
});
it('returns [] on malformed notice storage', () => {
localStorage.setItem('psysonic_theme_migration_notice', '{not json');
expect(readThemeMigrationNotice()).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { FIXED_THEMES } from '@/lib/themes/fixedThemes';
/**
* Slim-bundle theme migration (C5). Older builds bundled ~80 palettes that now
* live only in the community Theme Store. A persisted active / scheduler theme
* may therefore reference an id that is neither bundled (`FIXED_THEMES`) nor
* installed — it would resolve to no `[data-theme]` block and render as an
* unstyled `:root`.
*
* This runs synchronously in `runPreReactBootstrap` *before* React mounts,
* rewriting the persisted selection in localStorage so the store hydrates
* already-correct (no flash of the wrong/blank theme — Zustand rehydrate runs
* after first paint, so we read/write the persisted JSON directly, matching the
* other pre-React bootstrap steps).
*
* By design we do **not** auto-install anything from the store: an unresolved
* id simply falls back to a bundled theme — Mocha (dark) for the main + night
* slots, Latte (light) for the day slot. The fallback is always bundled, so the
* migration never needs the network and works offline.
*/
const THEME_KEY = 'psysonic_theme';
const INSTALLED_KEY = 'psysonic_installed_themes';
/** Set when the migration reset a theme, so the app can show a one-time notice. */
const NOTICE_KEY = 'psysonic_theme_migration_notice';
const FALLBACK_DARK = 'mocha';
const FALLBACK_LIGHT = 'latte';
const FIXED_IDS = new Set(FIXED_THEMES.map((t) => t.id));
/** Per-slot fallback: main + night are dark (Mocha), the day slot is light (Latte). */
const SLOT_FALLBACK = {
theme: FALLBACK_DARK,
themeDay: FALLBACK_LIGHT,
themeNight: FALLBACK_DARK,
} as const;
/** Ids of themes the user has installed from the store (persisted CSS lives here). */
function installedIds(): Set<string> {
try {
const raw = localStorage.getItem(INSTALLED_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw) as { state?: { themes?: Array<{ id?: unknown }> } };
const ids = (parsed.state?.themes ?? [])
.map((t) => t.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0);
return new Set(ids);
} catch {
return new Set();
}
}
export function migrateThemeSelection(): void {
try {
const raw = localStorage.getItem(THEME_KEY);
if (!raw) return; // fresh profile — defaults are bundled themes already
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
const state = parsed.state;
if (!state || typeof state !== 'object') return;
const installed = installedIds();
const isResolved = (id: unknown): boolean =>
typeof id === 'string' && (FIXED_IDS.has(id) || installed.has(id));
let changed = false;
const resetIds: string[] = [];
for (const slot of ['theme', 'themeDay', 'themeNight'] as const) {
const current = state[slot];
if (typeof current === 'string' && current.length > 0 && !isResolved(current)) {
resetIds.push(current);
state[slot] = SLOT_FALLBACK[slot];
changed = true;
}
}
if (changed) {
localStorage.setItem(THEME_KEY, JSON.stringify(parsed));
// Record what was reset so the app can show a one-time, dismissible notice.
const unique = [...new Set(resetIds)];
if (unique.length) localStorage.setItem(NOTICE_KEY, JSON.stringify(unique));
}
} catch {
// Malformed storage or non-browser runtime — non-fatal; the store falls back
// to its own bundled defaults on hydrate.
}
}
/** Ids of themes the migration reset (for the one-time notice); [] if none. */
export function readThemeMigrationNotice(): string[] {
try {
const raw = localStorage.getItem(NOTICE_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Dismiss the one-time migration notice. */
export function clearThemeMigrationNotice(): void {
try {
localStorage.removeItem(NOTICE_KEY);
} catch {
// ignore
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Tests for the Theme Store registry client: TTL cache, force refresh,
* stale-on-error fallback, and malformed-cache tolerance.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchRegistry, getCachedRegistry } from '@/lib/themes/themeRegistry';
const CACHE_KEY = 'psysonic_theme_registry_cache';
const NOW = 1_000_000_000;
const TTL = 12 * 60 * 60 * 1000;
const reg = (generatedAt = 't') => ({ schemaVersion: 1, generatedAt, themes: [] });
const okRes = (body: unknown) => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) });
const failRes = () => ({ ok: false, status: 500, json: async () => ({}), text: async () => '' });
function writeCache(ts: number, generatedAt: string): void {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts, registry: reg(generatedAt) }));
}
beforeEach(() => {
localStorage.clear();
vi.spyOn(Date, 'now').mockReturnValue(NOW);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
localStorage.clear();
});
describe('fetchRegistry', () => {
it('fetches and caches when there is no cache', async () => {
const fetchMock = vi.fn(async () => okRes(reg('fresh')));
vi.stubGlobal('fetch', fetchMock);
const r = await fetchRegistry();
expect(r.registry.generatedAt).toBe('fresh');
expect(r.stale).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(CACHE_KEY)).toContain('fresh');
});
it('returns a fresh cache without fetching', async () => {
writeCache(NOW, 'cached');
const fetchMock = vi.fn(async () => okRes(reg('network')));
vi.stubGlobal('fetch', fetchMock);
const r = await fetchRegistry();
expect(r.registry.generatedAt).toBe('cached');
expect(fetchMock).not.toHaveBeenCalled();
});
it('force-refreshes even with a fresh cache', async () => {
writeCache(NOW, 'cached');
const fetchMock = vi.fn(async () => okRes(reg('forced')));
vi.stubGlobal('fetch', fetchMock);
const r = await fetchRegistry({ force: true });
expect(r.registry.generatedAt).toBe('forced');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('falls back to a stale cache when the fetch fails', async () => {
writeCache(NOW - TTL - 1, 'stale'); // older than TTL → not fresh
vi.stubGlobal('fetch', vi.fn(async () => failRes()));
const r = await fetchRegistry();
expect(r.registry.generatedAt).toBe('stale');
expect(r.stale).toBe(true);
});
it('throws when the fetch fails and there is no cache', async () => {
vi.stubGlobal('fetch', vi.fn(async () => failRes()));
await expect(fetchRegistry()).rejects.toThrow();
});
it('treats a malformed cache as no cache and fetches', async () => {
localStorage.setItem(CACHE_KEY, '{ not valid json');
expect(getCachedRegistry()).toBeNull();
const fetchMock = vi.fn(async () => okRes(reg('fresh2')));
vi.stubGlobal('fetch', fetchMock);
const r = await fetchRegistry();
expect(r.registry.generatedAt).toBe('fresh2');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('fetches from GitHub raw, never a CDN', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string) => { calls.push(url); return okRes(reg('fresh')); }));
await fetchRegistry({ force: true });
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('raw.githubusercontent.com');
expect(calls[0]).not.toContain('jsdelivr');
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Theme Store registry client. Reads the auto-generated `registry.json` and each
* theme's CSS/thumbnail straight from the public `Psysonic/psysonic-themes` repo
* over GitHub raw (permissive CORS, ~5-minute server cache). The registry is
* cached in localStorage with a TTL so the store opens instantly and works
* offline against the last-seen catalogue.
*/
const RAW_BASE = 'https://raw.githubusercontent.com/Psysonic/psysonic-themes/main';
const REGISTRY_URL = `${RAW_BASE}/registry.json`;
const CACHE_KEY = 'psysonic_theme_registry_cache';
// Client-side cache lifetime: the store opens from this copy without a network
// round-trip and falls back to it when offline. The manual refresh button
// bypasses it, and GitHub raw is itself fresh (~5-min server cache).
const TTL_MS = 12 * 60 * 60 * 1000; // 12h
export interface RegistryTheme {
id: string;
name: string;
author: string;
version: string;
description: string;
mode: 'dark' | 'light';
tags?: string[];
/** True when the theme defines @keyframes (used for the CPU-load warning). */
animated?: boolean;
/** Repo-relative path to the theme's CSS. */
css: string;
/** Repo-relative path to the thumbnail. */
thumbnail: string;
/** ISO date of the last commit touching the theme in the registry repo. */
updatedAt?: string;
}
export interface Registry {
schemaVersion: number;
generatedAt: string;
themes: RegistryTheme[];
}
interface CacheEnvelope {
ts: number;
registry: Registry;
}
/** Absolute GitHub-raw URL for a repo-relative asset path (css / thumbnail). */
export function assetUrl(relPath: string): string {
return `${RAW_BASE}/${relPath}`;
}
function readCache(): CacheEnvelope | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const env = JSON.parse(raw) as CacheEnvelope;
if (!env || typeof env.ts !== 'number' || !env.registry) return null;
return env;
} catch {
return null;
}
}
function writeCache(registry: Registry): void {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), registry }));
} catch {
// Quota or serialization failure is non-fatal — we just re-fetch next time.
}
}
/** Last-seen registry regardless of age (for offline use). */
export function getCachedRegistry(): Registry | null {
return readCache()?.registry ?? null;
}
export interface FetchRegistryResult {
registry: Registry;
/** True when the network fetch failed and we served a cached copy instead. */
stale: boolean;
}
/**
* Fetch the registry. Returns the cached copy if it is still fresh, unless
* `force` is set (manual refresh). Falls back to a cached copy if the network
* fetch fails (flagged `stale: true`) so the store keeps working offline.
*/
export async function fetchRegistry(opts?: { force?: boolean }): Promise<FetchRegistryResult> {
if (!opts?.force) {
const cached = readCache();
if (cached && Date.now() - cached.ts < TTL_MS) return { registry: cached.registry, stale: false };
}
try {
const res = await fetch(REGISTRY_URL, { cache: 'no-cache' });
if (res.ok) {
const registry = (await res.json()) as Registry;
writeCache(registry);
return { registry, stale: false };
}
} catch {
// fall through to the cached copy below
}
const cached = readCache();
if (cached) return { registry: cached.registry, stale: true };
throw new Error('registry fetch failed');
}
/**
* Fetch a single theme's CSS text from GitHub raw (repo-relative path). Raw is
* used rather than a mutable CDN edge so an install or update always gets the
* current bytes: a stale edge would otherwise store pre-update CSS under the new
* version label, leaving the theme wrong with no further update to correct it.
*/
export async function fetchThemeCss(relPath: string): Promise<string> {
const res = await fetch(assetUrl(relPath), { cache: 'no-cache' });
if (!res.ok) throw new Error(`theme css fetch failed: ${res.status}`);
return res.text();
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Tests for uninstallTheme — removing a community theme must repair every
* selection slot that referenced it (active + scheduler day/night).
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { uninstallTheme } from '@/lib/themes/uninstallTheme';
import { useInstalledThemesStore, type InstalledTheme } from '@/store/installedThemesStore';
import { useThemeStore } from '@/store/themeStore';
function mk(id: string, mode: 'dark' | 'light' = 'dark'): InstalledTheme {
return { id, name: id, author: 'a', version: '1.0.0', description: '', mode, css: `[data-theme='${id}']{--accent:#fff;}`, installedAt: 0 };
}
beforeEach(() => {
useInstalledThemesStore.setState({ themes: [] });
useThemeStore.setState({ theme: 'mocha', themeDay: 'latte', themeNight: 'mocha' });
});
describe('uninstallTheme', () => {
it('removes the theme from the installed store', () => {
useInstalledThemesStore.getState().install(mk('dracula'));
uninstallTheme('dracula');
expect(useInstalledThemesStore.getState().isInstalled('dracula')).toBe(false);
});
it('resets an active dark theme to mocha', () => {
useInstalledThemesStore.getState().install(mk('dracula', 'dark'));
useThemeStore.setState({ theme: 'dracula' });
uninstallTheme('dracula');
expect(useThemeStore.getState().theme).toBe('mocha');
});
it('resets an active light theme to latte', () => {
useInstalledThemesStore.getState().install(mk('nord-snowstorm', 'light'));
useThemeStore.setState({ theme: 'nord-snowstorm' });
uninstallTheme('nord-snowstorm');
expect(useThemeStore.getState().theme).toBe('latte');
});
it('resets the scheduler day slot to latte', () => {
useInstalledThemesStore.getState().install(mk('dracula'));
useThemeStore.setState({ themeDay: 'dracula' });
uninstallTheme('dracula');
expect(useThemeStore.getState().themeDay).toBe('latte');
});
it('resets the scheduler night slot to mocha', () => {
useInstalledThemesStore.getState().install(mk('dracula'));
useThemeStore.setState({ themeNight: 'dracula' });
uninstallTheme('dracula');
expect(useThemeStore.getState().themeNight).toBe('mocha');
});
it('repairs every slot at once', () => {
useInstalledThemesStore.getState().install(mk('dracula', 'dark'));
useThemeStore.setState({ theme: 'dracula', themeDay: 'dracula', themeNight: 'dracula' });
uninstallTheme('dracula');
const s = useThemeStore.getState();
expect([s.theme, s.themeDay, s.themeNight]).toEqual(['mocha', 'latte', 'mocha']);
});
it('leaves unrelated slots untouched', () => {
useInstalledThemesStore.getState().install(mk('dracula'));
useThemeStore.setState({ theme: 'kanagawa-wave', themeDay: 'latte', themeNight: 'mocha' });
uninstallTheme('dracula');
const s = useThemeStore.getState();
expect([s.theme, s.themeDay, s.themeNight]).toEqual(['kanagawa-wave', 'latte', 'mocha']);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import { useThemeStore } from '@/store/themeStore';
/**
* Uninstall a community theme and repair any theme selection that pointed at it.
*
* A removed theme has no `[data-theme]` block, so every slot referencing its id
* must fall back to a bundled core — not just the manual `theme`, but the
* scheduler's `themeDay` / `themeNight` too. Without this, uninstalling a theme
* that is set as the day/night theme leaves the scheduler pointing at a missing
* id; when the clock crosses into that slot the app renders unstyled until the
* next launch (where the bootstrap migration would repair it).
*
* Fallback per slot: the active theme keeps its light/dark mode (Latte / Mocha);
* the day slot is light (Latte), the night slot is dark (Mocha).
*/
export function uninstallTheme(id: string): void {
const installed = useInstalledThemesStore.getState();
const wasLight = installed.getInstalled(id)?.mode === 'light';
installed.uninstall(id);
const t = useThemeStore.getState();
if (t.theme === id) t.setTheme(wasLight ? 'latte' : 'mocha');
if (t.themeDay === id) t.setThemeDay('latte');
if (t.themeNight === id) t.setThemeNight('mocha');
}
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { validateThemePackage } from '@/lib/themes/validateThemePackage';
/** A minimal floor-passing theme.css for `id`. */
function css(id = 'my-theme'): string {
return `[data-theme='${id}'] { color-scheme: dark; --accent: #abcdef; }`;
}
function manifest(over: Record<string, unknown> = {}): string {
return JSON.stringify({
id: 'my-theme',
name: 'My Theme',
author: 'tester',
version: '1.0.0',
description: 'A nice theme',
mode: 'dark',
...over,
});
}
const hasError = (r: ReturnType<typeof validateThemePackage>, re: RegExp): boolean =>
!r.ok && r.errors.some((e) => re.test(e));
describe('validateThemePackage', () => {
it('accepts a valid package', () => {
const r = validateThemePackage(manifest(), css());
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.theme.id).toBe('my-theme');
expect(r.theme.mode).toBe('dark');
expect(r.theme).not.toHaveProperty('tags');
}
});
it('accepts free-form CSS — foreign selectors and namespaced animations', () => {
const freeform = `
[data-theme='my-theme'] { color-scheme: dark; --accent: #abcdef; }
@keyframes my-theme-pulse { from { opacity: 1 } to { opacity: .5 } }
.sidebar { animation: my-theme-pulse 2s infinite; }
[data-theme='my-theme'][data-playing='true'] .player-bar { filter: brightness(1.1); }
`;
expect(validateThemePackage(manifest(), freeform).ok).toBe(true);
});
it('preserves valid tags', () => {
const r = validateThemePackage(manifest({ tags: ['dark', 'neon'] }), css());
expect(r.ok).toBe(true);
if (r.ok) expect(r.theme.tags).toEqual(['dark', 'neon']);
});
it('rejects invalid JSON', () => {
expect(hasError(validateThemePackage('{ not json', css()), /not valid JSON/)).toBe(true);
});
it('rejects a non-object manifest', () => {
expect(hasError(validateThemePackage('"a string"', css()), /must be a JSON object/)).toBe(true);
});
it('rejects unknown manifest properties', () => {
expect(hasError(validateThemePackage(manifest({ evil: true }), css()), /unknown property "evil"/)).toBe(true);
});
it('rejects a missing required field', () => {
expect(hasError(validateThemePackage(manifest({ name: undefined }), css()), /manifest\.name is required/)).toBe(true);
});
it('rejects an id that is not lowercase kebab-case', () => {
const r = validateThemePackage(manifest({ id: 'My_Theme' }), css('My_Theme'));
expect(hasError(r, /kebab-case/)).toBe(true);
});
it('rejects an id that collides with a built-in theme', () => {
const r = validateThemePackage(manifest({ id: 'mocha' }), css('mocha'));
expect(hasError(r, /collides with a built-in/)).toBe(true);
});
it('rejects CSS that reaches the network via @import', () => {
const r = validateThemePackage(manifest(), `@import 'https://evil/x.css'; ${css()}`);
expect(hasError(r, /failed the safety check/)).toBe(true);
});
it('rejects CSS with a non-data url() (containment)', () => {
const r = validateThemePackage(manifest(), `[data-theme='my-theme'] { background: url(https://evil/x.png); }`);
expect(hasError(r, /failed the safety check/)).toBe(true);
});
it('rejects @keyframes not namespaced with the theme id', () => {
const r = validateThemePackage(manifest(), `@keyframes pulse { from {} to {} } ${css()}`);
expect(hasError(r, /failed the safety check/)).toBe(true);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { validateThemeCss } from '@/lib/themes/themeInjection';
import { FIXED_THEMES } from '@/lib/themes/fixedThemes';
/**
* Validation for a locally imported theme package (a .zip holding manifest.json
* + theme.css). Community themes are free-form, so this enforces two things:
*
* 1. the **manifest** is well-formed (the same field rules as the repo schema),
* and its id doesn't collide with a built-in theme, and
* 2. the CSS passes the in-app **security floor** (`validateThemeCss`) — no
* network/scripts/breakout, data:-only `url()`, id-namespaced `@keyframes`.
*
* Quality, structure, animations and taste are deliberately NOT checked here:
* store themes are vetted by maintainers, and sideloaded themes are installed
* at the user's own risk (the import UI says so). The floor is the safety line.
*/
// Field patterns copied verbatim from the repo's schema/manifest.schema.json.
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const AUTHOR_RE = /^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$/;
const SEMVER_RE =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const APP_VER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const TAG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const MANIFEST_KEYS = new Set([
'id', 'name', 'author', 'version', 'description', 'mode', 'tags', 'minAppVersion',
]);
const BUILTIN_IDS = new Set(FIXED_THEMES.map((f) => f.id));
export interface ValidatedTheme {
id: string;
name: string;
author: string;
version: string;
description: string;
mode: 'dark' | 'light';
tags?: string[];
css: string;
}
export type ValidateResult =
| { ok: true; theme: ValidatedTheme }
| { ok: false; errors: string[] };
export function validateThemePackage(manifestText: string, css: string): ValidateResult {
const errors: string[] = [];
// ---- manifest ----
let m: Record<string, unknown>;
try {
const parsed = JSON.parse(manifestText);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return { ok: false, errors: ['manifest.json must be a JSON object'] };
}
m = parsed as Record<string, unknown>;
} catch (e) {
return { ok: false, errors: [`manifest.json is not valid JSON: ${(e as Error).message}`] };
}
for (const k of Object.keys(m)) {
if (!MANIFEST_KEYS.has(k)) errors.push(`manifest has an unknown property "${k}"`);
}
const str = (k: string): string | null => (typeof m[k] === 'string' ? (m[k] as string) : null);
const id = str('id');
if (id === null) errors.push('manifest.id is required and must be a string');
else {
if (!ID_RE.test(id) || id.length < 2 || id.length > 48) {
errors.push('manifest.id must be lowercase kebab-case, 248 chars');
}
if (BUILTIN_IDS.has(id)) errors.push(`manifest.id "${id}" collides with a built-in theme`);
}
const name = str('name');
if (name === null) errors.push('manifest.name is required and must be a string');
else if (name.length < 1 || name.length > 50) errors.push('manifest.name must be 150 chars');
const author = str('author');
if (author === null) errors.push('manifest.author is required and must be a string');
else if (!AUTHOR_RE.test(author)) errors.push('manifest.author must be a GitHub handle (no leading @)');
const version = str('version');
if (version === null) errors.push('manifest.version is required and must be a string');
else if (!SEMVER_RE.test(version)) errors.push('manifest.version must be a SemVer string (e.g. 1.0.0)');
const description = str('description');
if (description === null) errors.push('manifest.description is required and must be a string');
else if (description.length < 1 || description.length > 200) errors.push('manifest.description must be 1200 chars');
const mode = str('mode');
if (mode === null) errors.push('manifest.mode is required and must be a string');
else if (mode !== 'dark' && mode !== 'light') errors.push('manifest.mode must be "dark" or "light"');
if (m.tags !== undefined) {
const tags = m.tags;
if (!Array.isArray(tags)) errors.push('manifest.tags must be an array');
else {
if (tags.length > 8) errors.push('manifest.tags allows at most 8 items');
if (new Set(tags).size !== tags.length) errors.push('manifest.tags must be unique');
for (const tag of tags) {
if (typeof tag !== 'string' || !TAG_RE.test(tag) || tag.length > 24) {
errors.push(`manifest.tags has an invalid tag: ${JSON.stringify(tag)}`);
}
}
}
}
if (m.minAppVersion !== undefined) {
if (typeof m.minAppVersion !== 'string' || !APP_VER_RE.test(m.minAppVersion)) {
errors.push('manifest.minAppVersion must be a version like 1.2.3');
}
}
// ---- css security floor ----
// Needs a valid id (the @keyframes namespace check is keyed on it).
const idForCss = id && ID_RE.test(id) ? id : null;
if (idForCss === null) {
errors.push('theme.css cannot be validated until manifest.id is valid');
return { ok: false, errors };
}
if (validateThemeCss(css, idForCss) == null) {
errors.push(
"theme.css failed the safety check — it may exceed the size limit, reach the network (only data: url() is allowed), use @import / @property / scripts, break out of its <style>, or define @keyframes not namespaced as \"" + idForCss + "-…\"",
);
return { ok: false, errors };
}
if (errors.length) return { ok: false, errors };
return {
ok: true,
theme: {
id: id as string,
name: name as string,
author: author as string,
version: version as string,
description: description as string,
mode: mode as 'dark' | 'light',
...(Array.isArray(m.tags) ? { tags: m.tags as string[] } : {}),
css,
},
};
}