fix(dev): real metadata and session-only installs for theme-watch (#1314)

The watcher now ships the sibling manifest.json's name/author/version/
description/mode with each push, so watched themes keep their real
identity instead of dev placeholders and the registry update badge
stays quiet. Freshly seeded themes are marked dev: session-only,
excluded from persistence and from the update check, so a theme-watch
session leaves no trace in the user's installed themes. Both windows
subscribe again since dev themes cannot travel over the cross-window
storage sync; a rehydrate merge keeps them in memory.
This commit is contained in:
Psychotoxical
2026-07-16 23:06:27 +02:00
committed by GitHub
parent fa390e9211
commit e548c47078
5 changed files with 173 additions and 31 deletions
+37 -7
View File
@@ -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<PathBuf, (Option<SystemTime>, String)>;
type Seen = HashMap<PathBuf, (Option<SystemTime>, serde_json::Value)>;
let seen: Arc<Mutex<Seen>> = 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::<serde_json::Value>(&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));
+36 -23
View File
@@ -34,44 +34,57 @@ export default function App() {
}, [installedThemes]);
// Dev only: `--theme-watch <theme.css | dir>` (debug builds) pushes local
// theme CSS in on every save. Each payload is installed under the id in its
// `[data-theme='<id>']` 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='<id>']` 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<string>('theme-watch:css', ({ payload }) => install(payload, true)),
listen<string>('theme-watch:css-seed', ({ payload }) => install(payload, false)),
listen<WatchPayload>('theme-watch:css', ({ payload }) => install(payload, true)),
listen<WatchPayload>('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) {
@@ -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);
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useInstalledThemesStore, type InstalledTheme } from './installedThemesStore';
const theme = (id: string, over: Partial<InstalledTheme> = {}): 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();
});
});
+18
View File
@@ -20,6 +20,12 @@ export interface InstalledTheme {
/** The `[data-theme='<id>']` 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<InstalledThemesState>()(
{
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] };
},
}
)
);