feat(themes): fresher store refresh + external-services notice (#1016)

* feat(themes): fresher store refresh + external-services notice

Manual refresh now fetches the registry from GitHub raw first (~5 min
cache) and falls back to the jsDelivr CDN, so a freshly merged theme shows
up without waiting on — or purging — the shared CDN @main edge (up to
12 h). Normal loads still use the CDN.

Adds a notice on the Theme Store that the catalogue and previews load from
external services (jsDelivr / GitHub), in line with the app's network
transparency. i18n ×9.

* docs(themes): note the store-refresh PR in the Theme Store entry

Now that #1015 is merged, fold PR #1016 into the still-unreleased Theme
Store changelog and credits entry.
This commit is contained in:
Psychotoxical
2026-06-07 14:34:28 +02:00
committed by GitHub
parent 23f032b274
commit f4e1086131
14 changed files with 63 additions and 12 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Themes — community Theme Store
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015)**
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016)**
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
@@ -159,6 +159,11 @@ export function ThemeStoreSection() {
</button>
</div>
{/* Network disclosure — the store reaches external services. */}
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 1rem', lineHeight: 1.5 }}>
{t('settings.themeStoreNetworkNotice')}
</p>
{/* Toolbar: search + mode filter + refresh. Hidden when offline with no
catalogue to browse — the offline banner below stands in for it. */}
{!error && (
+1 -1
View File
@@ -356,7 +356,7 @@ const CONTRIBUTOR_ENTRIES = [
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, and free-form community themes (safety floor + state-reactive styling); 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015)',
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, and free-form community themes (safety floor + state-reactive styling); 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016)',
],
},
{
+1
View File
@@ -327,6 +327,7 @@ export const settings = {
themeStoreTitle: 'Theme-Store',
themeStoreSubmitText: 'Eigenes Theme gebaut? Teil es mit der Community — mehr Infos im Themes-Repository.',
themeStoreSubmitLink: 'Themes-Repository öffnen',
themeStoreNetworkNotice: 'Der Theme-Store lädt Katalog und Vorschauen von externen Diensten (jsDelivr-CDN und GitHub). Es werden keine persönlichen Daten gesendet.',
themeStoreSearchPlaceholder: 'Themes suchen…',
themeStoreFilterMode: 'Nach Modus filtern',
themeStoreModeAll: 'Alle',
+1
View File
@@ -394,6 +394,7 @@ export const settings = {
themeStoreTitle: 'Theme Store',
themeStoreSubmitText: 'Made your own theme? Share it with the community — more info in the themes repository.',
themeStoreSubmitLink: 'Open the themes repository',
themeStoreNetworkNotice: 'The Theme Store loads its catalogue and previews from external services (the jsDelivr CDN and GitHub). No personal data is sent.',
themeStoreSearchPlaceholder: 'Search themes…',
themeStoreFilterMode: 'Filter by mode',
themeStoreModeAll: 'All',
+1
View File
@@ -325,6 +325,7 @@ export const settings = {
themeStoreTitle: 'Tienda de temas',
themeStoreSubmitText: '¿Has creado tu propio tema? Compártelo con la comunidad — más información en el repositorio de temas.',
themeStoreSubmitLink: 'Abrir el repositorio de temas',
themeStoreNetworkNotice: 'La tienda de temas carga el catálogo y las vistas previas desde servicios externos (la CDN de jsDelivr y GitHub). No se envían datos personales.',
themeStoreSearchPlaceholder: 'Buscar temas…',
themeStoreFilterMode: 'Filtrar por modo',
themeStoreModeAll: 'Todos',
+1
View File
@@ -323,6 +323,7 @@ export const settings = {
themeStoreTitle: 'Boutique de thèmes',
themeStoreSubmitText: "Vous avez créé votre propre thème ? Partagez-le avec la communauté — plus d'infos dans le dépôt de thèmes.",
themeStoreSubmitLink: 'Ouvrir le dépôt de thèmes',
themeStoreNetworkNotice: 'Le Theme Store charge son catalogue et ses aperçus depuis des services externes (le CDN jsDelivr et GitHub). Aucune donnée personnelle nest envoyée.',
themeStoreSearchPlaceholder: 'Rechercher des thèmes…',
themeStoreFilterMode: 'Filtrer par mode',
themeStoreModeAll: 'Tous',
+1
View File
@@ -326,6 +326,7 @@ export const settings = {
themeStoreTitle: 'Temabutikk',
themeStoreSubmitText: 'Laget ditt eget tema? Del det med fellesskapet — mer info i tema-repoet.',
themeStoreSubmitLink: 'Åpne tema-repoet',
themeStoreNetworkNotice: 'Theme Store laster katalogen og forhåndsvisninger fra eksterne tjenester (jsDelivr-CDN og GitHub). Ingen personopplysninger sendes.',
themeStoreSearchPlaceholder: 'Søk i temaer…',
themeStoreFilterMode: 'Filtrer etter modus',
themeStoreModeAll: 'Alle',
+1
View File
@@ -323,6 +323,7 @@ export const settings = {
themeStoreTitle: 'Themawinkel',
themeStoreSubmitText: 'Eigen thema gemaakt? Deel het met de community — meer info in de themarepository.',
themeStoreSubmitLink: 'Themarepository openen',
themeStoreNetworkNotice: 'De Theme Store laadt de catalogus en previews van externe diensten (de jsDelivr-CDN en GitHub). Er worden geen persoonlijke gegevens verzonden.',
themeStoreSearchPlaceholder: "Thema's zoeken…",
themeStoreFilterMode: 'Filteren op modus',
themeStoreModeAll: 'Alle',
+1
View File
@@ -329,6 +329,7 @@ export const settings = {
themeStoreTitle: 'Magazin de teme',
themeStoreSubmitText: 'Ți-ai creat propria temă? Împărtășește-o cu comunitatea — mai multe informații în depozitul de teme.',
themeStoreSubmitLink: 'Deschide depozitul de teme',
themeStoreNetworkNotice: 'Theme Store încarcă catalogul și previzualizările de la servicii externe (CDN-ul jsDelivr și GitHub). Nu se trimit date personale.',
themeStoreSearchPlaceholder: 'Caută teme…',
themeStoreFilterMode: 'Filtrează după mod',
themeStoreModeAll: 'Toate',
+1
View File
@@ -405,6 +405,7 @@ export const settings = {
themeStoreTitle: 'Магазин тем',
themeStoreSubmitText: 'Создали свою тему? Поделитесь с сообществом — подробности в репозитории тем.',
themeStoreSubmitLink: 'Открыть репозиторий тем',
themeStoreNetworkNotice: 'Магазин тем загружает каталог и превью с внешних сервисов (CDN jsDelivr и GitHub). Личные данные не отправляются.',
themeStoreSearchPlaceholder: 'Поиск тем…',
themeStoreFilterMode: 'Фильтр по режиму',
themeStoreModeAll: 'Все',
+1
View File
@@ -322,6 +322,7 @@ export const settings = {
themeStoreTitle: '主题商店',
themeStoreSubmitText: '做了自己的主题?与社区分享——更多信息见主题仓库。',
themeStoreSubmitLink: '打开主题仓库',
themeStoreNetworkNotice: '主题商店从外部服务(jsDelivr CDN 和 GitHub)加载目录和预览。不会发送任何个人数据。',
themeStoreSearchPlaceholder: '搜索主题…',
themeStoreFilterMode: '按模式筛选',
themeStoreModeAll: '全部',
+27
View File
@@ -84,4 +84,31 @@ describe('fetchRegistry', () => {
expect(r.registry.generatedAt).toBe('fresh2');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('force-refresh tries GitHub raw first', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string) => { calls.push(url); return okRes(reg('forced')); }));
await fetchRegistry({ force: true });
expect(calls[0]).toContain('raw.githubusercontent.com');
});
it('force-refresh falls back to jsDelivr when raw fails', async () => {
const fetchMock = vi.fn(async (url: string) =>
url.includes('raw.githubusercontent.com') ? failRes() : okRes(reg('cdn')));
vi.stubGlobal('fetch', fetchMock);
const r = await fetchRegistry({ force: true });
expect(r.registry.generatedAt).toBe('cdn');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('non-force load uses the CDN only (not raw)', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string) => { calls.push(url); return okRes(reg('cdn')); }));
await fetchRegistry();
expect(calls).toHaveLength(1);
expect(calls[0]).not.toContain('raw.githubusercontent.com');
});
});
+20 -10
View File
@@ -7,6 +7,10 @@
const CDN_BASE = 'https://cdn.jsdelivr.net/gh/Psysonic/psysonic-themes@main';
const REGISTRY_URL = `${CDN_BASE}/registry.json`;
// GitHub raw serves with a ~5-minute cache (vs jsDelivr's up-to-12h @main edge)
// and permissive CORS. Used only on a manual refresh so freshly merged themes
// appear without waiting on — or purging — the shared CDN edge.
const RAW_REGISTRY_URL = 'https://raw.githubusercontent.com/Psysonic/psysonic-themes/main/registry.json';
const CACHE_KEY = 'psysonic_theme_registry_cache';
const TTL_MS = 12 * 60 * 60 * 1000; // 12h — matches jsDelivr's @main edge cache
@@ -81,17 +85,23 @@ export async function fetchRegistry(opts?: { force?: boolean }): Promise<FetchRe
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) throw new Error(`registry fetch failed: ${res.status}`);
const registry = (await res.json()) as Registry;
writeCache(registry);
return { registry, stale: false };
} catch (err) {
const cached = readCache();
if (cached) return { registry: cached.registry, stale: true };
throw err;
// On a manual refresh, try GitHub raw first (fresher) then fall back to the
// jsDelivr CDN; normal loads use the CDN only.
const sources = opts?.force ? [RAW_REGISTRY_URL, REGISTRY_URL] : [REGISTRY_URL];
for (const url of sources) {
try {
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) continue;
const registry = (await res.json()) as Registry;
writeCache(registry);
return { registry, stale: false };
} catch {
// try the next source
}
}
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 the CDN (repo-relative path). */