From 155ef88cc034a62fe25b021454a2ef067c21dfa0 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:24:43 +0200 Subject: [PATCH] fix(themes): serve the Theme Store from GitHub raw, not jsDelivr (#1051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theme assets (registry.json, theme CSS, thumbnails) were served from the jsDelivr CDN's mutable @main edge, which caches up to 12h. The registry is purged on every themes push so updates were offered promptly — but the theme CSS could still be served stale, so an update stored pre-update CSS under the new version label and no further update was offered to correct it (a freshly animated theme would install without its @keyframes). Fetch everything from GitHub raw (permissive CORS, ~5-min server cache), which is always current. jsDelivr is dropped entirely: our request volume does not need a CDN, and its only real upside here (high-traffic edge serving) does not apply, while its staleness actively caused the bug. Rename cdnUrl -> assetUrl to reflect the source. --- .../settings/ThemeStoreSection.test.tsx | 2 +- src/components/settings/ThemeStoreSection.tsx | 14 ++--- src/locales/de/settings.ts | 2 +- src/locales/en/settings.ts | 2 +- src/locales/es/settings.ts | 2 +- src/locales/fr/settings.ts | 2 +- src/locales/nb/settings.ts | 2 +- src/locales/nl/settings.ts | 2 +- src/locales/ro/settings.ts | 2 +- src/locales/ru/settings.ts | 2 +- src/locales/zh/settings.ts | 2 +- src/utils/themes/themeRegistry.test.ts | 25 ++------- src/utils/themes/themeRegistry.ts | 51 ++++++++++--------- 13 files changed, 47 insertions(+), 63 deletions(-) diff --git a/src/components/settings/ThemeStoreSection.test.tsx b/src/components/settings/ThemeStoreSection.test.tsx index a1c63d27..d16eb0c6 100644 --- a/src/components/settings/ThemeStoreSection.test.tsx +++ b/src/components/settings/ThemeStoreSection.test.tsx @@ -9,7 +9,7 @@ import type { FetchRegistryResult, Registry, RegistryTheme } from '@/utils/theme vi.mock('@/utils/themes/themeRegistry', () => ({ fetchRegistry: vi.fn(), fetchThemeCss: vi.fn(async () => 'css'), - cdnUrl: (p: string) => `https://cdn.example/${p}`, + assetUrl: (p: string) => `https://raw.example/${p}`, })); import { fetchRegistry } from '@/utils/themes/themeRegistry'; diff --git a/src/components/settings/ThemeStoreSection.tsx b/src/components/settings/ThemeStoreSection.tsx index 523efd5b..01425bbc 100644 --- a/src/components/settings/ThemeStoreSection.tsx +++ b/src/components/settings/ThemeStoreSection.tsx @@ -10,7 +10,7 @@ import { formatRelativeTime } from '../../utils/format/relativeTime'; import { useThemeStore } from '../../store/themeStore'; import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore'; import { - cdnUrl, + assetUrl, fetchRegistry, type RegistryTheme, } from '../../utils/themes/themeRegistry'; @@ -41,7 +41,7 @@ function pageItemsList(current: number, total: number): (number | 'gap')[] { } /** - * The community Theme Store: browse the jsDelivr-hosted registry, filter by name + * The community Theme Store: browse the GitHub-hosted registry, filter by name * and light/dark, install (fetch + persist + runtime inject), apply, update and * uninstall. Built-in themes are not in the registry, so they never appear here. */ @@ -82,12 +82,12 @@ export function ThemeStoreSection() { .finally(() => { setLoading(false); setRefreshing(false); }); }; - // Thumbnails live at a stable CDN path, so the webview caches them hard - // (jsDelivr sends max-age 7d). Tie a cache-buster to the registry's - // generatedAt — it changes on every themes push — so refreshed thumbnails - // show up after a registry refresh instead of being stuck on the old image. + // GitHub raw caches thumbnails only briefly, but the webview can still hold an + // old copy; tie a cache-buster to the registry's generatedAt — it changes on + // every themes push — so refreshed thumbnails show up after a registry refresh + // instead of being stuck on the old image. const thumbUrl = (rel: string) => - generatedAt ? `${cdnUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : cdnUrl(rel); + generatedAt ? `${assetUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : assetUrl(rel); useEffect(() => { load(false); }, []); diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index f7a374cc..55c3fd40 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -355,7 +355,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.', + themeStoreNetworkNotice: 'Der Theme-Store lädt Katalog und Vorschauen von GitHub. Es werden keine persönlichen Daten gesendet.', themeStoreStatsNotice: 'Die „Zuletzt geändert“-Daten werden einmal täglich aktualisiert (gegen 04:17 UTC).', themeStoreSearchPlaceholder: 'Themes suchen…', themeStoreFilterMode: 'Nach Modus filtern', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 8a41faf1..4d7c6906 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -399,7 +399,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.', + themeStoreNetworkNotice: 'The Theme Store loads its catalogue and previews from GitHub. No personal data is sent.', themeStoreStatsNotice: 'Last-changed dates refresh once a day (around 04:17 UTC).', themeStoreSearchPlaceholder: 'Search themes…', themeStoreFilterMode: 'Filter by mode', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index e09594f4..78da2ad8 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -353,7 +353,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.', + themeStoreNetworkNotice: 'La tienda de temas carga el catálogo y las vistas previas desde GitHub. No se envían datos personales.', themeStoreStatsNotice: 'Las fechas de última modificación se actualizan una vez al día (sobre las 04:17 UTC).', themeStoreSearchPlaceholder: 'Buscar temas…', themeStoreFilterMode: 'Filtrar por modo', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index c76dfc3b..a80a6769 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -351,7 +351,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 n’est envoyée.', + themeStoreNetworkNotice: 'Le Theme Store charge son catalogue et ses aperçus depuis GitHub. Aucune donnée personnelle n’est envoyée.', themeStoreStatsNotice: 'Les dates de dernière modification sont actualisées une fois par jour (vers 04h17 UTC).', themeStoreSearchPlaceholder: 'Rechercher des thèmes…', themeStoreFilterMode: 'Filtrer par mode', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 4ec7d664..fb6cc2bc 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -354,7 +354,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.', + themeStoreNetworkNotice: 'Theme Store laster katalogen og forhåndsvisninger fra GitHub. Ingen personopplysninger sendes.', themeStoreStatsNotice: 'Sist endret-datoene oppdateres én gang om dagen (rundt 04:17 UTC).', themeStoreSearchPlaceholder: 'Søk i temaer…', themeStoreFilterMode: 'Filtrer etter modus', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index bd2dccdc..f5e902fb 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -351,7 +351,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.', + themeStoreNetworkNotice: 'De Theme Store laadt de catalogus en previews van GitHub. Er worden geen persoonlijke gegevens verzonden.', themeStoreStatsNotice: 'De laatst-gewijzigd-datums worden eenmaal per dag bijgewerkt (rond 04:17 UTC).', themeStoreSearchPlaceholder: "Thema's zoeken…", themeStoreFilterMode: 'Filteren op modus', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 09c8e304..5af19467 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -357,7 +357,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.', + themeStoreNetworkNotice: 'Theme Store încarcă catalogul și previzualizările de la GitHub. Nu se trimit date personale.', themeStoreStatsNotice: 'Datele privind ultima modificare se actualizează o dată pe zi (în jurul orei 04:17 UTC).', themeStoreSearchPlaceholder: 'Caută teme…', themeStoreFilterMode: 'Filtrează după mod', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 9fcbd193..57b558c7 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -410,7 +410,7 @@ export const settings = { themeStoreTitle: 'Магазин тем', themeStoreSubmitText: 'Создали свою тему? Поделитесь с сообществом — подробности в репозитории тем.', themeStoreSubmitLink: 'Открыть репозиторий тем', - themeStoreNetworkNotice: 'Магазин тем загружает каталог и превью с внешних сервисов (CDN jsDelivr и GitHub). Личные данные не отправляются.', + themeStoreNetworkNotice: 'Магазин тем загружает каталог и превью с GitHub. Личные данные не отправляются.', themeStoreStatsNotice: 'Даты последнего изменения обновляются раз в день (около 04:17 UTC).', themeStoreSearchPlaceholder: 'Поиск тем…', themeStoreFilterMode: 'Фильтр по режиму', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index fbb430ae..ea9bab71 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -350,7 +350,7 @@ export const settings = { themeStoreTitle: '主题商店', themeStoreSubmitText: '做了自己的主题?与社区分享——更多信息见主题仓库。', themeStoreSubmitLink: '打开主题仓库', - themeStoreNetworkNotice: '主题商店从外部服务(jsDelivr CDN 和 GitHub)加载目录和预览。不会发送任何个人数据。', + themeStoreNetworkNotice: '主题商店从 GitHub 加载目录和预览。不会发送任何个人数据。', themeStoreStatsNotice: '最后更新时间每天更新一次(约 04:17 UTC)。', themeStoreSearchPlaceholder: '搜索主题…', themeStoreFilterMode: '按模式筛选', diff --git a/src/utils/themes/themeRegistry.test.ts b/src/utils/themes/themeRegistry.test.ts index 62754924..1cf6f9cc 100644 --- a/src/utils/themes/themeRegistry.test.ts +++ b/src/utils/themes/themeRegistry.test.ts @@ -85,30 +85,13 @@ describe('fetchRegistry', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('force-refresh tries GitHub raw first', async () => { + 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('forced')); })); + vi.stubGlobal('fetch', vi.fn(async (url: string) => { calls.push(url); return okRes(reg('fresh')); })); 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'); + expect(calls[0]).toContain('raw.githubusercontent.com'); + expect(calls[0]).not.toContain('jsdelivr'); }); }); diff --git a/src/utils/themes/themeRegistry.ts b/src/utils/themes/themeRegistry.ts index e036611c..5f2be33b 100644 --- a/src/utils/themes/themeRegistry.ts +++ b/src/utils/themes/themeRegistry.ts @@ -1,18 +1,18 @@ /** - * Theme Store registry client. Reads the auto-generated `registry.json` from the - * public `Psysonic/psysonic-themes` repo via the jsDelivr CDN (CORS-enabled, - * globally cached). The registry is cached in localStorage with a TTL so the - * store opens instantly and works offline against the last-seen catalogue. + * 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 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 RAW_BASE = 'https://raw.githubusercontent.com/Psysonic/psysonic-themes/main'; +const REGISTRY_URL = `${RAW_BASE}/registry.json`; const CACHE_KEY = 'psysonic_theme_registry_cache'; -const TTL_MS = 12 * 60 * 60 * 1000; // 12h — matches jsDelivr's @main edge 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; @@ -45,9 +45,9 @@ interface CacheEnvelope { registry: Registry; } -/** Absolute CDN URL for a repo-relative path (css / thumbnail). */ -export function cdnUrl(relPath: string): string { - return `${CDN_BASE}/${relPath}`; +/** 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 { @@ -91,28 +91,29 @@ export async function fetchRegistry(opts?: { force?: boolean }): Promise { - const res = await fetch(cdnUrl(relPath), { cache: 'no-cache' }); + const res = await fetch(assetUrl(relPath), { cache: 'no-cache' }); if (!res.ok) throw new Error(`theme css fetch failed: ${res.status}`); return res.text(); }