chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard

This commit is contained in:
HorizonCode committed 2026-10-29 10:53:12 +01:00
1 parent 417f54a94a
commit 87202de656
97 files changed
+10968 -1422

No files matched your search

+295 -168
View File
@@ -1,8 +1,9 @@
import { active_custom_theme, custom_themes } from './global';
import { active_custom_theme, custom_theme_audio_playing, custom_themes } from './global';
import { clearThemeMediaCache } from './themeMedia';
import { isThemeV2, normalizeThemeV2, type ThemeV2Asset, type ThemeV2Definition } from './themeV2';
import { disableMediaControls, setGlobalVolume } from './utils';
import { betterFetch } from '@better-fetch/fetch';
import { convertFileSrc } from '@tauri-apps/api/core';
import { invoke } from '@tauri-apps/api/core';
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';
@@ -12,7 +13,10 @@ export type Theme = {
folder_name: string;
name: string;
version: string;
apiVersion: string;
author: string;
description: string;
nsfw: boolean;
scriptUrl: string;
cssStyle: string;
assets: string;
@@ -20,16 +24,19 @@ export type Theme = {
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
progress: number;
updateAvailable: boolean;
definition?: ThemeV2Definition;
};
export type ThemeInfo = {
name: string;
version: string;
apiVersion: string;
author: string;
entry: string;
style: string;
preview: string;
version?: string;
apiVersion?: string;
author?: string;
description?: string;
nsfw?: boolean;
entry?: string;
style?: string;
preview?: string;
};
type DownloadProgress = {
@@ -47,6 +54,44 @@ type ExtractProgress = {
current_file: string;
};
const THEME_REPOSITORY = 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes';
let themeAssetBasePromise: Promise<string> | 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<string> => {
themeAssetBasePromise ??= invoke<string>('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[] = [];
@@ -55,8 +100,11 @@ export const getDownloadableThemes = async () => {
name: string;
version: string;
author: string;
apiVersion?: string;
description?: string;
nsfw?: boolean;
}[]
>('https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes.json', {
>(`${THEME_REPOSITORY}/raw/branch/main/themes.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -71,10 +119,13 @@ export const getDownloadableThemes = async () => {
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: `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(theme.name)}.png`,
preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(theme.name)}.png`,
status: 'not-installed',
progress: 0,
updateAvailable: false,
@@ -88,6 +139,83 @@ export const getDownloadableThemes = async () => {
return themes;
};
const themeFromFolder = async (
folderName: string,
folderPath: string
): Promise<Theme | undefined> => {
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<ThemeV2Definition>;
if (!parsed.name) return undefined;
const apiVersion = parsed.apiVersion ?? '1.0';
const isV2 = isThemeV2(apiVersion);
const definition = isV2
? normalizeThemeV2(parsed as unknown as Partial<ThemeV2Definition>)
: 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<Theme[]> => {
const themes: Theme[] = [];
themes.push({
@@ -95,6 +223,9 @@ export const getThemes = async (): Promise<Theme[]> => {
author: 'EZPPFarm',
name: 'Default',
version: '1.0.0',
apiVersion: '1.0',
description: 'The default EZPPLauncher look.',
nsfw: false,
scriptUrl: '',
assets: '',
preview: '',
@@ -103,36 +234,16 @@ export const getThemes = async (): Promise<Theme[]> => {
updateAvailable: false,
cssStyle: '',
});
const folderPath = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const folderPath = await themesDirectory();
if (await fs.exists(folderPath)) {
const themeFolders = await fs.readDir(folderPath);
for (const themeFolder of themeFolders) {
const themeFolderPath = await path.join(folderPath, themeFolder.name);
const themeConfigFilePath = await path.join(folderPath, themeFolder.name, 'theme.json');
if (await fs.exists(themeConfigFilePath)) {
const themeConfig = await fs.readTextFile(themeConfigFilePath);
const theme = JSON.parse(themeConfig) as ThemeInfo;
const scriptFile = await path.join(folderPath, themeFolder.name, theme.entry);
const cssStylePath = await path.join(folderPath, themeFolder.name, theme.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
if (await fs.exists(scriptFile)) {
themes.push({
folder_name: themeFolder.name,
name: theme.name,
version: theme.version,
author: theme.author,
scriptUrl: convertFileSrc(await path.normalize(`${themeFolderPath}/${theme.entry}`)),
assets: convertFileSrc(await path.normalize(`${themeFolderPath}/assets`)),
preview: convertFileSrc(
await path.normalize(`${themeFolderPath}/assets/${theme.preview}`)
),
status: 'installed',
progress: 0,
updateAvailable: false,
cssStyle,
});
}
}
const theme = await themeFromFolder(
themeFolder.name,
await path.join(folderPath, themeFolder.name)
);
if (theme) themes.push(theme);
}
}
return themes;
@@ -144,11 +255,7 @@ export const downloadTheme = async (
): Promise<{ success: boolean; error?: string }> => {
if (theme.status !== 'not-installed' && !force) return { success: false };
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'downloading';
return themes;
});
patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' }));
const themeFileName = `${toSafeName(theme.name)}.ezpplauncher-theme`;
const themeContents = await betterFetch<{
@@ -158,55 +265,56 @@ export const downloadTheme = async (
);
if (themeContents.error) {
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
return {
success: false,
error: themeContents.error.message ?? 'An unknown error occurred',
};
}
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const themeFolder = await path.join(baseThemeFolder, toSafeName(theme.name));
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 = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${themeFileName}`;
const downloadUrl = `${THEME_REPOSITORY}/raw/branch/main/themes/${themeFileName}`;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'downloading';
matchedTheme.progress = 0;
}
return themes;
});
patchTheme(theme.name, (current) => ({
...current,
status: 'downloading',
progress: 0,
}));
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<DownloadProgress>('download_progress', (event) => {
if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'downloading';
matchedTheme.progress = event.payload.progress;
}
return themes;
});
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<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'extracting';
matchedTheme.progress = event.payload.progress;
}
return themes;
});
beginExtraction(event.payload.progress);
});
try {
@@ -219,50 +327,55 @@ export const downloadTheme = async (
} catch (err) {
console.log(err);
const erro = err as Error;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
return { success: false, error: erro.message || 'Failed to download or extract theme.' };
} finally {
unlistenDownload();
unlistenExtract();
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
// 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);
}
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
matchedTheme.folder_name = toSafeName(theme.name);
}
return themes;
});
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) {
patchTheme(theme.name, (current) => ({ ...current, status: '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 });
@@ -275,7 +388,7 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
if (themeToUninstall.status !== 'installed') {
return false;
}
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const baseThemeFolder = await themesDirectory();
if (!(await fs.exists(baseThemeFolder))) {
return false;
}
@@ -283,27 +396,19 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
if (!(await fs.exists(themeFolder))) {
return false;
}
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name);
if (matchedTheme) {
matchedTheme.status = 'deleting';
}
return themes;
});
patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'deleting' }));
await fs.remove(themeFolder, { recursive: true });
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name);
if (matchedTheme) {
matchedTheme.scriptUrl = '';
matchedTheme.cssStyle = '';
matchedTheme.assets = '';
matchedTheme.preview = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`;
matchedTheme.status = 'not-installed';
matchedTheme.updateAvailable = false;
}
return themes;
});
patchTheme(themeToUninstall.name, (current) => ({
...current,
scriptUrl: '',
cssStyle: '',
assets: '',
definition: undefined,
preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`,
status: 'not-installed',
updateAvailable: false,
}));
await reloadThemes();
return true;
@@ -327,18 +432,14 @@ export const checkThemeFromFile = async (filePath: string) => {
};
export const importThemeFromFile = async (themeName: string, filePath: string) => {
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
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<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== themeName) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeName);
if (matchedTheme) matchedTheme.progress = event.payload.progress;
return themes;
});
patchTheme(themeName, (current) => ({ ...current, progress: event.payload.progress }));
});
try {
@@ -354,30 +455,9 @@ export const importThemeFromFile = async (themeName: string, filePath: string) =
unlisten();
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeInfoObj.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
}
return themes;
});
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 };
@@ -396,8 +476,12 @@ export const reloadThemes = async () => {
const installed = installedMap.get(downloadable.name);
if (installed) {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true;
try {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true;
}
} catch {
// Ignore malformed versions instead of breaking the whole theme list.
}
} else {
combinedMap.set(downloadable.name, downloadable);
@@ -410,21 +494,31 @@ export const reloadThemes = async () => {
const storeMap = new Map(storeThemes.map((t) => [t.name, t]));
const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']);
for (const theme of combinedThemes) {
const storeTheme = storeMap.get(theme.name);
if (!storeTheme || ACTIVE_STATUSES.has(storeTheme.status)) continue;
const updatedThemes = storeThemes.map((storeTheme) => {
if (ACTIVE_STATUSES.has(storeTheme.status)) return storeTheme;
const theme = combinedMap.get(storeTheme.name);
if (!theme) return storeTheme;
storeTheme.scriptUrl = theme.scriptUrl;
storeTheme.assets = theme.assets;
storeTheme.preview = theme.preview;
storeTheme.version = theme.version;
storeTheme.author = theme.author;
storeTheme.updateAvailable = theme.updateAvailable;
storeTheme.cssStyle = theme.cssStyle;
}
// 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,
cssStyle: theme.cssStyle,
};
});
const newThemes = combinedThemes.filter((t) => !storeMap.has(t.name));
const merged = [...storeThemes, ...newThemes];
const merged = [...updatedThemes, ...newThemes];
return merged.sort((a, b) => {
if (a.name === 'Default') return -1;
@@ -434,6 +528,39 @@ export const reloadThemes = async () => {
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 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) => {