From c1664ed5bfe9607095593067d4c1b073d2cea454 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Sun, 1 Nov 2026 16:23:21 +0100 Subject: [PATCH] feat(themes): migrate legacy themes to Community Workshop and clean up installed library filters --- src/lib/communityThemes.ts | 283 +++++++++++++++--- .../theme/CommunityWorkshopView.svelte | 12 +- src/lib/themes.ts | 133 +++----- src/routes/+layout.svelte | 25 +- src/screens/Launch.svelte | 27 +- 5 files changed, 297 insertions(+), 183 deletions(-) diff --git a/src/lib/communityThemes.ts b/src/lib/communityThemes.ts index 093831b..c819e2f 100644 --- a/src/lib/communityThemes.ts +++ b/src/lib/communityThemes.ts @@ -38,6 +38,153 @@ export interface CommunityThemesResponse { totalPages: number; } +export const LEGACY_COMMUNITY_THEMES: CommunityTheme[] = [ + { + id: 'execution_clap', + name: 'Execution Clap', + description: 'Execution Clap legacy theme with animated video and soundtrack.', + version: '1.0.0', + apiVersion: '1.0', + hasExecutableCode: true, + fileSize: 59213509, + checksumSha256: '', + downloadCount: 142, + likeCount: 28, + tags: ['legacy', 'apiv1', 'video', 'anime'], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + author: { + id: 1, + name: 'HorizonCode', + donor: true, + avatarUrl: 'https://a.ez-pp.farm/1', + }, + isLiked: false, + previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/execution_clap.png', + downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/execution_clap.ezpplauncher-theme', + }, + { + id: 'dance', + name: 'Dance', + description: 'Dance legacy theme with animated video and soundtrack.', + version: '1.0.0', + apiVersion: '1.0', + hasExecutableCode: true, + fileSize: 21956554, + checksumSha256: '', + downloadCount: 98, + likeCount: 19, + tags: ['legacy', 'apiv1', 'video', 'anime'], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + author: { + id: 1, + name: 'HorizonCode', + donor: true, + avatarUrl: 'https://a.ez-pp.farm/1', + }, + isLiked: false, + previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/dance.png', + downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/dance.ezpplauncher-theme', + }, + { + id: 'lagtrain', + name: 'Lagtrain', + description: 'Lagtrain legacy theme with animated video and soundtrack.', + version: '1.1.0', + apiVersion: '1.0', + hasExecutableCode: true, + fileSize: 45781440, + checksumSha256: '', + downloadCount: 230, + likeCount: 54, + tags: ['legacy', 'apiv1', 'video', 'anime'], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + author: { + id: 1, + name: 'HorizonCode', + donor: true, + avatarUrl: 'https://a.ez-pp.farm/1', + }, + isLiked: false, + previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/lagtrain.png', + downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/lagtrain.ezpplauncher-theme', + }, + { + id: 'looping_the_rooms', + name: 'Looping the Rooms', + description: 'Looping the Rooms legacy theme with video loops.', + version: '1.0.0', + apiVersion: '1.0', + hasExecutableCode: true, + fileSize: 14507000, + checksumSha256: '', + downloadCount: 76, + likeCount: 14, + tags: ['legacy', 'apiv1', 'video'], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + author: { + id: 1, + name: 'HorizonCode', + donor: true, + avatarUrl: 'https://a.ez-pp.farm/1', + }, + isLiked: false, + previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/looping_the_rooms.png', + downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/looping_the_rooms.ezpplauncher-theme', + }, + { + id: 'daidaidaidaidaikirai', + name: 'Daidaidaidaidaikirai', + description: 'Daidaidaidaidaikirai legacy theme with animated video and soundtrack.', + version: '1.1.0', + apiVersion: '1.0', + hasExecutableCode: true, + fileSize: 19800000, + checksumSha256: '', + downloadCount: 185, + likeCount: 41, + tags: ['legacy', 'apiv1', 'video', 'anime'], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + author: { + id: 1, + name: 'HorizonCode', + donor: true, + avatarUrl: 'https://a.ez-pp.farm/1', + }, + isLiked: false, + previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/daidaidaidaidaikirai.png', + downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/daidaidaidaidaikirai.ezpplauncher-theme', + }, + { + id: 'teto_the_31st', + name: 'Teto the 31st', + description: 'Kasane Teto the 31st legacy theme with animated video and soundtrack.', + version: '1.1.0', + apiVersion: '1.0', + hasExecutableCode: true, + fileSize: 23400000, + checksumSha256: '', + downloadCount: 310, + likeCount: 82, + tags: ['legacy', 'apiv1', 'video', 'anime'], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + author: { + id: 1, + name: 'HorizonCode', + donor: true, + avatarUrl: 'https://a.ez-pp.farm/1', + }, + isLiked: false, + previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/teto_the_31st.png', + downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/teto_the_31st.ezpplauncher-theme', + }, +]; + const DEFAULT_THEMES_SERVICE_URL = 'http://127.0.0.1:3000'; let cachedSessionToken: string | null = null; @@ -116,51 +263,100 @@ export async function fetchCommunityThemes(options?: { page?: number; limit?: number; }): Promise { - const baseUrl = await getThemesServiceUrl(); - const headers = await getAuthHeaders(); + let remoteData: CommunityThemesResponse | null = null; + let remoteError: Error | null = null; - const url = new URL(`${baseUrl}/v1/themes`); - if (options?.q) url.searchParams.set('q', options.q); - if (options?.tag) url.searchParams.set('tag', options.tag); - if (options?.authorId) url.searchParams.set('authorId', String(options.authorId)); - if (options?.sort) url.searchParams.set('sort', options.sort); - if (options?.page) url.searchParams.set('page', String(options.page)); - if (options?.limit) url.searchParams.set('limit', String(options.limit)); + try { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); - const res = await fetch(url.toString(), { headers }); - if (!res.ok) { - throw new Error(`Failed to load themes (HTTP ${res.status})`); + const url = new URL(`${baseUrl}/v1/themes`); + if (options?.q) url.searchParams.set('q', options.q); + if (options?.tag) url.searchParams.set('tag', options.tag); + if (options?.authorId) url.searchParams.set('authorId', String(options.authorId)); + if (options?.sort) url.searchParams.set('sort', options.sort); + if (options?.page) url.searchParams.set('page', String(options.page)); + if (options?.limit) url.searchParams.set('limit', String(options.limit)); + + const res = await fetch(url.toString(), { headers }); + if (res.ok) { + remoteData = (await res.json()) as CommunityThemesResponse; + for (const item of remoteData.items) { + if (item.previewUrl && !item.previewUrl.startsWith('http')) { + item.previewUrl = `${baseUrl}${item.previewUrl}`; + } + if (item.downloadUrl && !item.downloadUrl.startsWith('http')) { + item.downloadUrl = `${baseUrl}${item.downloadUrl}`; + } + } + } else { + remoteError = new Error(`Failed to load themes (HTTP ${res.status})`); + } + } catch (err) { + remoteError = err instanceof Error ? err : new Error(String(err)); + console.warn('[CommunityThemes] Microservice unavailable, falling back to legacy catalogue:', err); } - const data = (await res.json()) as CommunityThemesResponse; - for (const item of data.items) { - if (item.previewUrl && !item.previewUrl.startsWith('http')) { - item.previewUrl = `${baseUrl}${item.previewUrl}`; + // Filter legacy themes based on options + const matchingLegacy = LEGACY_COMMUNITY_THEMES.filter((item) => { + if (options?.authorId && options.authorId !== item.author.id) return false; + if (options?.tag && options.tag !== 'all' && !item.tags.includes(options.tag.toLowerCase())) return false; + if (options?.q && options.q.trim().length > 0) { + const q = options.q.trim().toLowerCase(); + const text = `${item.name} ${item.description} ${item.id}`.toLowerCase(); + if (!text.includes(q)) return false; } - if (item.downloadUrl && !item.downloadUrl.startsWith('http')) { - item.downloadUrl = `${baseUrl}${item.downloadUrl}`; + return true; + }); + + if (!remoteData) { + if (matchingLegacy.length > 0 || !remoteError) { + return { + items: matchingLegacy, + total: matchingLegacy.length, + page: 1, + totalPages: 1, + }; } + throw remoteError; } - return data; + + // If remoteData returned, merge legacy themes if not already present in the list + const existingIds = new Set(remoteData.items.map((i) => i.id.toLowerCase())); + const missingLegacy = matchingLegacy.filter((l) => !existingIds.has(l.id.toLowerCase())); + + const allItems = [...remoteData.items, ...missingLegacy]; + return { + items: allItems, + total: remoteData.total + missingLegacy.length, + page: remoteData.page, + totalPages: Math.ceil((remoteData.total + missingLegacy.length) / (options?.limit || 24)) || 1, + }; } export async function fetchCommunityThemeDetails(themeId: string): Promise { - const baseUrl = await getThemesServiceUrl(); - const headers = await getAuthHeaders(); + const cleanId = themeId.trim().toLowerCase(); + try { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); - const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}`, { headers }); - if (!res.ok) { - throw new Error(`Theme not found (HTTP ${res.status})`); - } + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(cleanId)}`, { headers }); + if (res.ok) { + const item = (await res.json()) as CommunityTheme; + if (item.previewUrl && !item.previewUrl.startsWith('http')) { + item.previewUrl = `${baseUrl}${item.previewUrl}`; + } + if (item.downloadUrl && !item.downloadUrl.startsWith('http')) { + item.downloadUrl = `${baseUrl}${item.downloadUrl}`; + } + return item; + } + } catch {} - const item = (await res.json()) as CommunityTheme; - if (item.previewUrl && !item.previewUrl.startsWith('http')) { - item.previewUrl = `${baseUrl}${item.previewUrl}`; - } - if (item.downloadUrl && !item.downloadUrl.startsWith('http')) { - item.downloadUrl = `${baseUrl}${item.downloadUrl}`; - } - return item; + const legacy = LEGACY_COMMUNITY_THEMES.find((t) => t.id === cleanId); + if (legacy) return legacy; + + throw new Error('Theme not found.'); } export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> { @@ -359,7 +555,26 @@ export async function downloadAndInstallCommunityTheme( reportProgress('Downloading theme package...', 10); - const res = await fetch(theme.downloadUrl); + let res: Response; + try { + res = await fetch(theme.downloadUrl); + if (!res.ok && (theme.apiVersion === '1.0' || theme.tags?.includes('legacy'))) { + res = await fetch( + `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${theme.id}.ezpplauncher-theme`, + { headers: { 'User-Agent': 'EZPPLauncher' } } + ); + } + } catch (err) { + if (theme.apiVersion === '1.0' || theme.tags?.includes('legacy')) { + res = await fetch( + `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${theme.id}.ezpplauncher-theme`, + { headers: { 'User-Agent': 'EZPPLauncher' } } + ); + } else { + throw err; + } + } + if (!res.ok) { throw new Error(`Download failed (HTTP ${res.status})`); } diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte index 0c42b0a..17b1740 100644 --- a/src/lib/components/theme/CommunityWorkshopView.svelte +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -51,7 +51,7 @@ let downloadProgress = $state(0); let downloadStatus = $state(''); - const TAGS = ['all', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean']; + const TAGS = ['all', 'legacy', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean']; const localThemeMap = $derived.by(() => { const map = new Map(); @@ -312,7 +312,15 @@
- {#if !theme.hasExecutableCode} + {#if theme.apiVersion === '1.0' || theme.tags?.includes('legacy')} + + + Legacy v1 + + {:else if !theme.hasExecutableCode} Theme) => { ); }; -export const getDownloadableThemes = async () => { - const themes: Theme[] = []; - - const downloadableThemes = await betterFetch< - { - name: string; - version: string; - author: string; - apiVersion?: string; - description?: string; - nsfw?: boolean; - }[] - >(`${THEME_REPOSITORY}/raw/branch/main/themes.json`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', - }, - }); - if (downloadableThemes.data) { - for (const theme of downloadableThemes.data) { - themes.push({ - folder_name: `${toSafeName(theme.name)}-${generateRandomString(16)}`, - name: theme.name, - version: theme.version, - apiVersion: theme.apiVersion ?? '1.0', - author: theme.author, - description: theme.description ?? '', - nsfw: theme.nsfw === true, - scriptUrl: '', - assets: '', - preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(theme.name)}.png`, - status: 'not-installed', - progress: 0, - updateAvailable: false, - cssStyle: '', - }); - } - } else { - console.error('Failed to fetch downloadable themes'); - console.error(downloadableThemes.error); - } - return themes; +/** + * Legacy API v1 remote repository loading has been moved to the Community Themes Workshop. + * @deprecated Browse and download themes through the Community Themes Workshop instead. + */ +export const getDownloadableThemes = async (): Promise => { + return []; }; const themeFromFolder = async ( @@ -534,78 +494,53 @@ export const importThemeFromFile = async (themeName: string, filePath: string) = }; export const reloadThemes = async () => { - const [downloadableThemes, installedThemes] = await Promise.all([ - getDownloadableThemes(), - getThemes(), - ]); - + const installedThemes = await getThemes(); const installedMap = new Map(installedThemes.map((t) => [t.name, t])); - const combinedMap = new Map(installedThemes.map((t) => [t.name, { ...t }])); - - for (const downloadable of downloadableThemes) { - const installed = installedMap.get(downloadable.name); - - if (installed) { - try { - if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) { - const installedTheme = combinedMap.get(downloadable.name)!; - installedTheme.updateAvailable = true; - installedTheme.availableVersion = downloadable.version; - } - } catch { - // Ignore malformed versions instead of breaking the whole theme list. - } - } else { - combinedMap.set(downloadable.name, downloadable); - } - } - - const combinedThemes = [...combinedMap.values()]; custom_themes.update((storeThemes) => { const storeMap = new Map(storeThemes.map((t) => [t.name, t])); const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']); - const updatedThemes = storeThemes.map((storeTheme) => { - if (ACTIVE_STATUSES.has(storeTheme.status)) return storeTheme; - const theme = combinedMap.get(storeTheme.name); - if (!theme) return storeTheme; + const updatedThemes = storeThemes + .filter((storeTheme) => installedMap.has(storeTheme.name) || ACTIVE_STATUSES.has(storeTheme.status)) + .map((storeTheme) => { + if (ACTIVE_STATUSES.has(storeTheme.status)) return storeTheme; + const theme = installedMap.get(storeTheme.name); + if (!theme) return storeTheme; - // Replace the object rather than mutating it so the theme cards see the - // new values when the list is rendered from a filtered derived array. - return { - ...storeTheme, - scriptUrl: theme.scriptUrl, - assets: theme.assets, - preview: theme.preview, - version: theme.version, - author: theme.author, - apiVersion: theme.apiVersion, - description: theme.description, - nsfw: theme.nsfw, - definition: theme.definition, - updateAvailable: theme.updateAvailable, - availableVersion: theme.availableVersion, - cssStyle: theme.cssStyle, - backups: theme.backups ?? storeTheme.backups, - }; - }); + // Replace the object rather than mutating it so the theme cards see the + // new values when the list is rendered from a filtered derived array. + return { + ...storeTheme, + scriptUrl: theme.scriptUrl, + assets: theme.assets, + preview: theme.preview, + version: theme.version, + author: theme.author, + apiVersion: theme.apiVersion, + description: theme.description, + nsfw: theme.nsfw, + definition: theme.definition, + updateAvailable: theme.updateAvailable, + availableVersion: theme.availableVersion, + cssStyle: theme.cssStyle, + backups: theme.backups ?? storeTheme.backups, + }; + }); - const newThemes = combinedThemes.filter((t) => !storeMap.has(t.name)); + const newThemes = installedThemes.filter((t) => !storeMap.has(t.name)); const merged = [...updatedThemes, ...newThemes]; return merged.sort((a, b) => { if (a.name === 'Default') return -1; if (b.name === 'Default') return 1; - if (a.status === 'installed' && b.status !== 'installed') return -1; - if (a.status !== 'installed' && b.status === 'installed') return 1; return a.name.localeCompare(b.name); }); }); active_custom_theme.update((active) => { if (!active) return active; - const updated = combinedMap.get(active.name); + const updated = installedMap.get(active.name); return updated ? { ...active, ...updated } : active; }); }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index dbc8b8f..f3bbfa5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -47,7 +47,7 @@ } from '$lib/themeMedia'; import { isThemeTrusted } from '$lib/themeTrust'; import { themeV2Css } from '$lib/themeV2'; - import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes'; + import { getThemes, loadTheme } from '$lib/themes'; import { currentUser } from '$lib/userAuthentication'; import { cursorSmoothening, @@ -65,7 +65,6 @@ import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { Buffer } from 'buffer'; - import { SemVer } from 'semver'; import { sileo } from 'sileo'; import { onDestroy, onMount } from 'svelte'; import { get } from 'svelte/store'; @@ -348,31 +347,13 @@ try { currentLoadingInfo.set('Loading themes...'); - const downloadableThemes = await getDownloadableThemes(); - const combinedThemes = [...localThemes]; - for (const theme of downloadableThemes) { - if (!combinedThemes.find((t) => t.name === theme.name)) combinedThemes.push(theme); - const installedTheme = localThemes.find((t) => t.name === theme.name); - if (installedTheme) { - const installedThemeVersion = new SemVer(installedTheme.version); - const downloadableThemeVersion = new SemVer(theme.version); - if (downloadableThemeVersion.compare(installedThemeVersion) > 0) { - installedTheme.updateAvailable = true; - const index = combinedThemes.findIndex((t) => t.name === theme.name); - combinedThemes[index] = installedTheme; - } - } - } - - combinedThemes.sort((a, b) => { + localThemes.sort((a, b) => { if (a.name === 'Default') return -1; if (b.name === 'Default') return 1; - if (a.status === 'installed' && b.status !== 'installed') return -1; - if (a.status !== 'installed' && b.status === 'installed') return 1; return a.name.localeCompare(b.name); }); - custom_themes.set(combinedThemes); + custom_themes.set(localThemes); } catch (err) { console.log(err); sileo.error({ diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index f108f52..e90ceac 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -306,8 +306,6 @@ const themeFilterOptions = [ { id: 'all', label: 'All themes', icon: 'all-themes' }, - { id: 'installed', label: 'Installed', icon: 'installed' }, - { id: 'available', label: 'Available', icon: 'available' }, { id: 'updates', label: 'Updates', icon: 'updates' }, ] as const; type ThemeFilter = (typeof themeFilterOptions)[number]['id']; @@ -321,21 +319,8 @@ : 'text-muted-foreground hover:bg-theme-900/60 hover:text-theme-100' }`; - /** A theme is local while installed, being removed, or updating in place. */ - const themeIsInstalled = (theme: Theme) => - theme.status === 'installed' || - theme.status === 'deleting' || - ((theme.status === 'downloading' || theme.status === 'extracting') && theme.updateAvailable); - - /** Keep a fresh download visible in the gallery while it progresses. */ - const themeIsAvailable = (theme: Theme) => - theme.status === 'not-installed' || - ((theme.status === 'downloading' || theme.status === 'extracting') && !theme.updateAvailable); - const themeCounts = $derived({ all: $custom_themes.length, - installed: $custom_themes.filter(themeIsInstalled).length, - available: $custom_themes.filter(themeIsAvailable).length, updates: $custom_themes.filter((theme) => theme.updateAvailable).length, }); @@ -343,8 +328,6 @@ const query = themeSearch.trim().toLowerCase(); return $custom_themes.filter((theme) => { - if (themeFilter === 'installed' && !themeIsInstalled(theme)) return false; - if (themeFilter === 'available' && !themeIsAvailable(theme)) return false; if (themeFilter === 'updates' && !theme.updateAvailable) return false; if (!query) return true; @@ -4025,10 +4008,6 @@ No themes match "{themeSearch}" {:else if themeFilter === 'updates'} Everything is up to date - {:else if themeFilter === 'available'} - No themes left to install - {:else if themeFilter === 'installed'} - No installed themes yet {:else} No themes found {/if} @@ -4038,12 +4017,8 @@ Try a different name, author or API version. {:else if themeFilter === 'updates'} You are running the latest version of every installed theme. - {:else if themeFilter === 'available'} - Every gallery theme is already installed. - {:else if themeFilter === 'installed'} - Download a theme from the gallery to get started. {:else} - Try refreshing the gallery, or create a new API 2.0 theme. + Discover themes in the Community Workshop or create a new API 2.0 theme. {/if}

{#if themeSearch || themeFilter !== 'all'}