diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b540ccc..28612ae0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -445,11 +445,11 @@ pub fn run() { } // Per-file state: mtime (gates the read while a - // file is unchanged) and last pushed contents + // file is unchanged) and last pushed payload // (change detection + ready re-send). Entries only // update after a successful emit, so a failed push // retries next tick instead of being marked seen. - type Seen = HashMap, String)>; + type Seen = HashMap, serde_json::Value)>; let seen: Arc> = Arc::new(Mutex::new(HashMap::new())); // The frontend announces its listeners (on mount @@ -473,8 +473,8 @@ pub fn run() { let handle = app.handle().clone(); app.listen("theme-watch:ready", move |_| { let Ok(m) = seen.lock() else { return }; - for (_, css) in m.values() { - let _ = handle.emit(ready_event, css); + for (_, payload) in m.values() { + let _ = handle.emit(ready_event, payload); } }); } @@ -512,7 +512,10 @@ pub fn run() { }; let event = match m.get_mut(&f) { // mtime-only touch — restamp quietly. - Some(entry) if entry.1 == css => { + Some(entry) + if entry.1.get("css").and_then(|c| c.as_str()) + == Some(css.as_str()) => + { entry.0 = mtime; continue; } @@ -524,8 +527,35 @@ pub fn run() { } _ => "theme-watch:css", }; - if handle.emit(event, &css).is_ok() { - m.insert(f, (mtime, css)); + // Ship the sibling manifest's metadata so + // the frontend can show the theme's real + // name/author/version instead of dev + // placeholders (and the update badge + // stays quiet). + let manifest = f + .parent() + .map(|d| d.join("manifest.json")) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| { + serde_json::from_str::(&s).ok() + }); + let meta = |k: &str| { + manifest + .as_ref() + .and_then(|v| v.get(k)) + .and_then(|v| v.as_str()) + .map(String::from) + }; + let payload = serde_json::json!({ + "css": css, + "name": meta("name"), + "author": meta("author"), + "version": meta("version"), + "description": meta("description"), + "mode": meta("mode"), + }); + if handle.emit(event, &payload).is_ok() { + m.insert(f, (mtime, payload)); } } std::thread::sleep(std::time::Duration::from_millis(300)); diff --git a/src/App.tsx b/src/App.tsx index ae89c673..53c98215 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,44 +34,57 @@ export default function App() { }, [installedThemes]); // Dev only: `--theme-watch ` (debug builds) pushes local - // theme CSS in on every save. Each payload is installed under the id in its - // `[data-theme='']` selector — the syncInjectedThemes effect above - // re-injects, so authoring is live without re-importing a zip. - // `theme-watch:css` also applies (single file, or a save in a watched - // directory); `theme-watch:css-seed` only installs (directory startup - // sweep), so authors can switch between a themes-repo checkout's themes in - // the UI. Never wired in production. + // theme CSS (+ sibling manifest.json metadata) in on every save. Each + // payload is installed under the id in its `[data-theme='']` selector — + // the syncInjectedThemes effect above re-injects, so authoring is live + // without re-importing a zip. `theme-watch:css` also applies (single file, + // or a save in a watched directory); `theme-watch:css-seed` only installs + // (directory startup sweep), so authors can switch between a themes-repo + // checkout's themes in the UI. Both windows subscribe: dev-seeded themes + // are session-only (excluded from persistence), so the mini player cannot + // get them through the cross-window storage sync. Never wired in + // production. useEffect(() => { if (!import.meta.env.DEV) return; - // Main window only: the mini player shares the same persisted store — a - // second subscriber would double every install and could write a stale - // whole-store snapshot over changes made in the main window. - if (getWindowKind() !== 'main') return; const unlisteners: (() => void)[] = []; void import('@tauri-apps/api/event').then(({ listen, emit }) => { - const install = (css: string, apply: boolean) => { - const id = css.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1]; + type WatchPayload = { + css: string; + name?: string | null; + author?: string | null; + version?: string | null; + description?: string | null; + mode?: string | null; + }; + const install = (payload: WatchPayload, apply: boolean) => { + const css = payload?.css; + const id = css?.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1]; if (!id) return; - // Only the CSS is the dev payload — keep a store-installed copy's - // metadata and installedAt, so watching a checkout doesn't rebrand - // real installs as 'dev' or reshuffle the Themes grid. + // Manifest metadata wins, then a store-installed copy's, then dev + // placeholders — watched themes keep their real identity, and only + // the CSS is the live payload. Fresh seeds are marked dev + // (session-only, never persisted); a store-installed theme being + // watched keeps its persisted entry. const prev = useInstalledThemesStore.getState().getInstalled(id); useInstalledThemesStore.getState().install({ id, - name: prev?.name ?? id, - author: prev?.author ?? 'dev', - version: prev?.version ?? '0.0.0', - description: prev?.description ?? '', - mode: prev?.mode ?? 'dark', + name: payload.name ?? prev?.name ?? id, + author: payload.author ?? prev?.author ?? 'dev', + version: payload.version ?? prev?.version ?? '0.0.0', + description: payload.description ?? prev?.description ?? '', + mode: payload.mode === 'light' || payload.mode === 'dark' + ? payload.mode + : prev?.mode ?? 'dark', tags: prev?.tags, css, installedAt: prev?.installedAt ?? Date.now(), + dev: prev ? prev.dev ?? false : true, }); if (apply) useThemeStore.getState().setTheme(id); }; const subs = [ - listen('theme-watch:css', ({ payload }) => install(payload, true)), - listen('theme-watch:css-seed', ({ payload }) => install(payload, false)), + listen('theme-watch:css', ({ payload }) => install(payload, true)), + listen('theme-watch:css-seed', ({ payload }) => install(payload, false)), ]; // Guard the mocked-in-tests case where listen() isn't a promise. for (const sub of subs) { diff --git a/src/features/settings/hooks/useThemeUpdates.ts b/src/features/settings/hooks/useThemeUpdates.ts index 2eba7d97..885f9617 100644 --- a/src/features/settings/hooks/useThemeUpdates.ts +++ b/src/features/settings/hooks/useThemeUpdates.ts @@ -32,7 +32,11 @@ export function useThemeUpdates(): RegistryTheme[] { return useMemo(() => { if (!registry) return []; - const installedVersionById = new Map(installed.map(t => [t.id, t.version])); + // Dev theme-watch copies are session-only working state — offering a + // registry "update" for them would overwrite the author's local work. + const installedVersionById = new Map( + installed.filter(t => !t.dev).map(t => [t.id, t.version]), + ); return registry.themes.filter(rt => { const current = installedVersionById.get(rt.id); return current != null && isNewer(rt.version, current); diff --git a/src/store/installedThemesStore.test.ts b/src/store/installedThemesStore.test.ts new file mode 100644 index 00000000..dd1b8fed --- /dev/null +++ b/src/store/installedThemesStore.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useInstalledThemesStore, type InstalledTheme } from './installedThemesStore'; + +const theme = (id: string, over: Partial = {}): InstalledTheme => ({ + id, + name: id, + author: 'tester', + version: '1.0.0', + description: '', + mode: 'dark', + css: `[data-theme='${id}'] { --accent: #000; }`, + installedAt: 1, + ...over, +}); + +beforeEach(() => { + localStorage.clear(); + useInstalledThemesStore.setState({ themes: [] }); +}); + +describe('installedThemesStore', () => { + it('replaces an existing theme in place, keeping its grid position', () => { + const s = useInstalledThemesStore.getState(); + s.install(theme('a')); + s.install(theme('b')); + s.install(theme('c')); + useInstalledThemesStore.getState().install(theme('b', { version: '1.0.1' })); + expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['a', 'b', 'c']); + expect(useInstalledThemesStore.getState().getInstalled('b')?.version).toBe('1.0.1'); + }); + + it('appends a new theme at the end', () => { + const s = useInstalledThemesStore.getState(); + s.install(theme('a')); + s.install(theme('b')); + expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['a', 'b']); + }); + + it('keeps dev themes out of persisted storage', () => { + const s = useInstalledThemesStore.getState(); + s.install(theme('real')); + s.install(theme('wip', { dev: true })); + const raw = localStorage.getItem('psysonic_installed_themes'); + const stored = JSON.parse(raw ?? '{}') as { state?: { themes?: InstalledTheme[] } }; + expect(stored.state?.themes?.map(t => t.id)).toEqual(['real']); + // In-memory state still has both. + expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['real', 'wip']); + }); + + it('keeps in-memory dev themes across a rehydrate', async () => { + const s = useInstalledThemesStore.getState(); + s.install(theme('real')); + s.install(theme('wip', { dev: true })); + // Simulate another window persisting a change (cross-window storage sync + // rehydrates this window from the new snapshot). + localStorage.setItem( + 'psysonic_installed_themes', + JSON.stringify({ state: { themes: [theme('real', { version: '2.0.0' })] }, version: 1 }), + ); + await useInstalledThemesStore.persist.rehydrate(); + expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['real', 'wip']); + expect(useInstalledThemesStore.getState().getInstalled('real')?.version).toBe('2.0.0'); + }); + + it('drops a dev theme on rehydrate when storage has a real install of the same id', async () => { + useInstalledThemesStore.getState().install(theme('x', { dev: true })); + localStorage.setItem( + 'psysonic_installed_themes', + JSON.stringify({ state: { themes: [theme('x', { version: '3.0.0' })] }, version: 1 }), + ); + await useInstalledThemesStore.persist.rehydrate(); + const only = useInstalledThemesStore.getState().themes; + expect(only).toHaveLength(1); + expect(only[0].version).toBe('3.0.0'); + expect(only[0].dev).toBeUndefined(); + }); +}); diff --git a/src/store/installedThemesStore.ts b/src/store/installedThemesStore.ts index 4ac19cd2..0e39222d 100644 --- a/src/store/installedThemesStore.ts +++ b/src/store/installedThemesStore.ts @@ -20,6 +20,12 @@ export interface InstalledTheme { /** The `[data-theme='']` block — the only CSS, already CI-validated. */ css: string; installedAt: number; + /** + * Session-only copy pushed by the dev `--theme-watch` sweep. Never written + * to storage (see partialize/merge below), so a dev session leaves no trace + * in the user's installed themes. + */ + dev?: boolean; } interface InstalledThemesState { @@ -51,6 +57,18 @@ export const useInstalledThemesStore = create()( { name: 'psysonic_installed_themes', version: 1, + // Dev theme-watch copies are session-only: partialize keeps them out of + // storage, and merge keeps the in-memory ones across a rehydrate (the + // cross-window storage sync rehydrates on every write from the other + // window — without this, a persisted change would wipe them). + partialize: (s) => ({ themes: s.themes.filter((t) => !t.dev) }), + merge: (persisted, current) => { + const stored = (persisted as { themes?: InstalledTheme[] } | undefined)?.themes ?? []; + const dev = current.themes.filter( + (t) => t.dev && !stored.some((p) => p.id === t.id), + ); + return { ...current, themes: [...stored, ...dev] }; + }, } ) );