mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(lib,playback): fold utils/cache+themes into lib; playbackScheduleFormat→playback (drains utils/format)
This commit is contained in:
+66
@@ -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
@@ -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 };
|
||||
}
|
||||
Vendored
+38
@@ -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;
|
||||
}
|
||||
Vendored
+20
@@ -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() }); },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, 2–48 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 1–50 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 1–200 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user