mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
+37
-7
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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] };
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user