import { active_custom_theme, custom_theme_audio_playing, custom_themes } from './global'; import { clearThemeMediaCache, playThemeMedia } from './themeMedia'; import { isThemeV2, normalizeThemeV2, type ThemeV2Asset, type ThemeV2Definition } from './themeV2'; import { disableMediaControls, setGlobalVolume } from './utils'; import { betterFetch } from '@better-fetch/fetch'; import { convertFileSrc, invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import * as path from '@tauri-apps/api/path'; import * as fs from '@tauri-apps/plugin-fs'; import { SemVer } from 'semver'; export type Theme = { folder_name: string; name: string; version: string; apiVersion: string; author: string; description: string; nsfw: boolean; scriptUrl: string; cssStyle: string; assets: string; preview: string; status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed'; progress: number; updateAvailable: boolean; /** The version available in the repository when an update is pending. */ availableVersion?: string; definition?: ThemeV2Definition; backups?: ThemeBackup[]; }; export type ThemeBackup = { path: string; label: string; createdAt: number; size: number; }; export type ThemeInfo = { name: string; version?: string; apiVersion?: string; author?: string; description?: string; nsfw?: boolean; entry?: string; style?: string; preview?: string; }; type DownloadProgress = { theme_name: string; received: number; total: number; progress: number; }; type ExtractProgress = { theme_name: string; total: number; extracted: number; progress: number; current_file: string; }; const THEME_REPOSITORY = 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes'; let themeAssetBasePromise: Promise | undefined; /** * Linux cannot load theme media from the custom asset protocol, so the native * side exposes a loopback HTTP base for theme files. Everywhere else this is * empty and the normal asset protocol is used. */ export const themeAssetBase = (): Promise => { themeAssetBasePromise ??= invoke('theme_asset_base').catch(() => ''); return themeAssetBasePromise; }; const encodeThemePath = (value: string) => value .replace(/\\/g, '/') .replace(/^\.?\//, '') .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); export const themesDirectory = async () => await path.join(await path.homeDir(), '.ezpplauncher', 'themes'); /** * Replace a theme in the store instead of mutating it. The theme list is * rendered from a filtered derived list, and Svelte treats each item as * immutable there, so in-place mutation would not update the cards (e.g. the * download status and progress would stay at their initial values). */ const patchTheme = (name: string, patch: (theme: Theme) => Theme) => { custom_themes.update((themes) => themes.map((theme) => (theme.name === name ? patch(theme) : 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; }; const themeFromFolder = async ( folderName: string, folderPath: string ): Promise => { const themeConfigFilePath = await path.join(folderPath, 'theme.json'); if (!(await fs.exists(themeConfigFilePath))) return undefined; try { const themeConfig = await fs.readTextFile(themeConfigFilePath); const parsed = JSON.parse(themeConfig) as ThemeInfo & Partial; if (!parsed.name) return undefined; const apiVersion = parsed.apiVersion ?? '1.0'; const isV2 = isThemeV2(apiVersion); const definition = isV2 ? normalizeThemeV2(parsed as unknown as Partial) : undefined; const httpBase = await themeAssetBase(); const httpFolder = httpBase ? `${httpBase}/${encodeURIComponent(folderName)}` : ''; const entry = parsed.entry ?? ''; let scriptUrl = ''; if (!isV2 && entry) { const scriptFile = await path.join(folderPath, entry); if (!(await fs.exists(scriptFile))) return undefined; scriptUrl = httpFolder ? `${httpFolder}/${encodeThemePath(entry)}` : convertFileSrc(await path.normalize(scriptFile)); } const style = parsed.style ?? ''; let cssStyle = ''; if (style) { const cssStylePath = await path.join(folderPath, style); if (await fs.exists(cssStylePath)) cssStyle = await fs.readTextFile(cssStylePath); } const assets = httpFolder ? `${httpFolder}/assets` : convertFileSrc(await path.normalize(await path.join(folderPath, 'assets'))); const previewName = parsed.preview || (isV2 ? 'preview.png' : ''); let preview = ''; if (previewName) { const previewPath = await path.join(folderPath, 'assets', previewName); if (await fs.exists(previewPath)) { // The editor rewrites preview.png on every save. A cache-busting query // makes the WebView pick up the new file instead of its cached asset. preview = httpFolder ? `${httpFolder}/assets/${encodeThemePath(previewName)}?v=${Date.now()}` : `${convertFileSrc(await path.normalize(previewPath))}?v=${Date.now()}`; } } return { folder_name: folderName, name: parsed.name, version: parsed.version ?? '1.0.0', apiVersion, author: parsed.author ?? 'Unknown', description: parsed.description ?? '', nsfw: parsed.nsfw === true, scriptUrl, assets, preview, status: 'installed', progress: 0, updateAvailable: false, cssStyle, definition, }; } catch (error) { console.error(`Failed to read theme ${folderName}`, error); return undefined; } }; export const getThemes = async (): Promise => { const themes: Theme[] = []; themes.push({ folder_name: 'default', author: 'EZPPFarm', name: 'Default', version: '1.0.0', apiVersion: '', description: 'The default EZPPLauncher look.', nsfw: false, scriptUrl: '', assets: '', preview: '', status: 'installed', progress: 0, updateAvailable: false, cssStyle: '', }); const folderPath = await themesDirectory(); if (await fs.exists(folderPath)) { const themeFolders = await fs.readDir(folderPath); for (const themeFolder of themeFolders) { // Finder drops .DS_Store into this folder; never let a stray file or an // unreadable package hide every other installed theme. if (!themeFolder.isDirectory || themeFolder.name.startsWith('.')) continue; const themeFolderPath = await path.join(folderPath, themeFolder.name); try { const theme = await themeFromFolder(themeFolder.name, themeFolderPath); if (theme) { try { theme.backups = await invoke('list_theme_backups', { themeFolder: themeFolderPath, }); } catch { theme.backups = []; } themes.push(theme); } } catch (error) { console.error(`Failed to load theme ${themeFolder.name}`, error); } } } return themes; }; export const downloadTheme = async ( theme: Theme, force = false ): Promise<{ success: boolean; error?: string }> => { if (theme.status !== 'not-installed' && !force) return { success: false }; const wasInstalled = theme.status === 'installed'; patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' })); const themeFileName = `${toSafeName(theme.name)}.ezpplauncher-theme`; const themeContents = await betterFetch<{ file_contents: { sha: string; download_url: string }; }>( `https://git.ez-pp.farm/api/v1/repos/EZPPFarm/EZPPLauncher-Themes/contents-ext/themes/${themeFileName}?ref=main` ); if (themeContents.error) { patchTheme(theme.name, (current) => ({ ...current, status: wasInstalled ? 'installed' : 'not-installed', })); return { success: false, error: themeContents.error.message ?? 'An unknown error occurred', }; } const baseThemeFolder = await themesDirectory(); const folderName = toSafeName(theme.name); const themeFolder = await path.join(baseThemeFolder, folderName); if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true }); if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true }); const downloadUrl = `${THEME_REPOSITORY}/raw/branch/main/themes/${themeFileName}`; patchTheme(theme.name, (current) => ({ ...current, status: 'downloading', progress: 0, })); let updateBackup: string | undefined; const restoreUpdateBackup = async () => { if (!updateBackup) return; try { await invoke('restore_theme_backup', { themeFolder, backupFolder: updateBackup, }); } catch (error) { console.error('Could not restore the theme backup after a failed update', error); } }; let extractionStartedAt = 0; const beginExtraction = (progress: number) => { if (extractionStartedAt === 0) extractionStartedAt = Date.now(); patchTheme(theme.name, (current) => ({ ...current, status: 'extracting', progress, })); }; const unlistenDownload = await listen('download_progress', (event) => { if (event.payload.theme_name !== theme.name) return; if (event.payload.total > 0 && event.payload.received >= event.payload.total) { beginExtraction(0); return; } patchTheme(theme.name, (current) => ({ ...current, status: 'downloading', progress: event.payload.progress, })); }); const unlistenExtract = await listen('extract_progress', (event) => { if (event.payload.theme_name !== theme.name) return; beginExtraction(event.payload.progress); }); try { // Keep the currently installed files before an update overwrites them. // The backup also covers a failed/partial download so the user can roll back. if (theme.status === 'installed') { try { updateBackup = await invoke('backup_theme_folder', { themeFolder }); } catch (error) { console.warn('Could not back up the theme before updating it', error); } } await invoke('download_and_extract_theme', { downloadUrl, expectedSha: themeContents.data.file_contents.sha, themeFolder, themeName: theme.name, }); } catch (err) { console.log(err); const erro = err as Error; await restoreUpdateBackup(); patchTheme(theme.name, (current) => ({ ...current, status: wasInstalled ? 'installed' : 'not-installed', })); return { success: false, error: erro.message || 'Failed to download or extract theme.' }; } finally { unlistenDownload(); unlistenExtract(); } // Small themes can extract in a few milliseconds, before the webview paints // the extracting state. Keep it visible long enough to be readable, while // still letting large themes finish naturally. if (extractionStartedAt === 0) { beginExtraction(0); } const minimumExtractionMs = 700; const elapsed = Date.now() - extractionStartedAt; if (elapsed < minimumExtractionMs) { await new Promise((resolve) => setTimeout(resolve, minimumExtractionMs - elapsed)); } const installedTheme = await themeFromFolder(folderName, themeFolder); if (!installedTheme) { await restoreUpdateBackup(); patchTheme(theme.name, (current) => ({ ...current, status: wasInstalled ? 'installed' : 'not-installed', })); return { success: false, error: 'The downloaded theme package is invalid.' }; } patchTheme(theme.name, (current) => ({ ...current, ...installedTheme, status: 'installed', progress: 1, })); await reloadThemes(); return { success: true }; }; export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volume = 0.15) => { // Theme media is buffered as blob URLs; the previous theme's URLs are no // longer needed once it is replaced. clearThemeMediaCache(); active_custom_theme.set(theme); custom_theme_audio_playing.set(false); themeContainer.innerHTML = ''; if (theme.definition) { setGlobalVolume(volume); disableMediaControls(); return; } if (theme.scriptUrl.length > 0) { const themeScript = await import(/* @vite-ignore */ theme.scriptUrl); themeScript.mountTheme(themeContainer, { assets: theme.assets }); } setGlobalVolume(volume); disableMediaControls(); // Ensure all media elements mounted by v1 themes start playback automatically themeContainer.querySelectorAll('video, audio').forEach((media) => { media.muted = false; media.volume = volume; playThemeMedia(media); }); void invoke('unlock_media_autoplay').catch(() => {}); }; export const deleteTheme = async (themeToUninstall: Theme) => { if (themeToUninstall.status !== 'installed') { return false; } const baseThemeFolder = await themesDirectory(); if (!(await fs.exists(baseThemeFolder))) { return false; } const themeFolder = await path.join(baseThemeFolder, themeToUninstall.folder_name); if (!(await fs.exists(themeFolder))) { return false; } patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'deleting' })); try { await fs.remove(themeFolder, { recursive: true }); } catch (error) { patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'installed' })); throw error; } // Remove the local card entirely, then let reloadThemes add it back as a // downloadable theme only when the repository actually offers it. Marking // local-only themes as "not-installed" left them stuck in the gallery with // an unusable Download Theme button. custom_themes.update((themes) => themes.filter((theme) => theme.name !== themeToUninstall.name)); await reloadThemes(); return true; }; export const checkThemeFromFile = async (filePath: string) => { const normalizedFilePath = await path.normalize(filePath); if (!(await fs.exists(normalizedFilePath))) { return { success: false, error: 'File not found' }; } try { const themeInfo = await invoke('read_theme_info', { filePath: normalizedFilePath, }); return { success: true, themeInfo }; } catch (err) { console.log(err); return { success: false, error: 'Could not read theme file' }; } }; export const importThemeFromFile = async (themeName: string, filePath: string) => { const baseThemeFolder = await themesDirectory(); const themeFolder = await path.join(baseThemeFolder, toSafeName(themeName)); if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true }); if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true }); const unlisten = await listen('extract_progress', (event) => { if (event.payload.theme_name !== themeName) return; patchTheme(themeName, (current) => ({ ...current, progress: event.payload.progress })); }); try { await invoke('extract_theme', { filePath, themeFolder, themeName, }); } catch (err) { console.log(err); return { success: false, error: 'Failed to extract theme file.' }; } finally { unlisten(); } if (!(await themeFromFolder(toSafeName(themeName), themeFolder))) { return { success: false, error: 'The theme package is missing its theme.json or entry file.' }; } await reloadThemes(); return { success: true }; }; export const reloadThemes = async () => { const [downloadableThemes, installedThemes] = await Promise.all([ getDownloadableThemes(), 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; // 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 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); return updated ? { ...active, ...updated } : active; }); }; export const saveThemeProject = async ( folderName: string, definition: ThemeV2Definition, assets: ThemeV2Asset[] = [] ) => { const themeFolder = await path.join(await themesDirectory(), folderName); await invoke('save_theme_project', { themeFolder, config: JSON.stringify(definition, null, 2), assets, }); return themeFolder; }; export const exportThemeFolder = async (folderName: string, destinationPath: string) => { const themeFolder = await path.join(await themesDirectory(), folderName); await invoke('export_theme', { themeFolder, destinationPath }); }; export const listThemeBackups = async (themeFolder: string) => await invoke('list_theme_backups', { themeFolder }); export const restoreThemeBackup = async (themeFolder: string, backupFolder: string) => await invoke('restore_theme_backup', { themeFolder, backupFolder }); export const exportTheme = async (theme: Theme, destinationPath: string) => exportThemeFolder(theme.folder_name, destinationPath); export const openThemesFolder = async () => { const folder = await themesDirectory(); await invoke('open_theme_folder', { themeFolder: folder }); }; export const toSafeName = (name: string) => { return ( name .toLowerCase() .replace(/[<>:"/\\|?*]/g, '_') // eslint-disable-next-line no-control-regex .replace(/[\x00-\x1F\x7F]/g, '_') .replace(/[\s-]+/g, '_') .replace(/_+/g, '_') .replace(/[. ]+$/g, '_') ); }; const generateRandomString = (length: number) => { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; return Array.from({ length }, () => characters.charAt(Math.floor(Math.random() * characters.length)) ).join(''); };