Crossfade user-selected themes, keep the theme volume popover open while dragging its slider, and clip the fixed app shell so oversized theme layers cannot create page scrollbars on Windows. Remove the redundant in-use badge from the active theme card.
768 lines
25 KiB
TypeScript
768 lines
25 KiB
TypeScript
import { active_custom_theme, custom_theme_audio_playing, custom_themes } from '../global';
|
|
import { disableMediaControls, setGlobalVolume } from '../utils';
|
|
import { clearThemeMediaCache, playThemeMedia } from './themeMedia';
|
|
import { isThemeV2, normalizeThemeV2, type ThemeV2Asset, type ThemeV2Definition } from './themeV2';
|
|
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 { tick } from 'svelte';
|
|
|
|
export type Theme = {
|
|
folder_name: string;
|
|
/** Server-assigned workshop ID, when this theme has been published. */
|
|
workshopId?: string;
|
|
name: string;
|
|
version: string;
|
|
apiVersion: string;
|
|
author: string;
|
|
/** EZPPFarm user ID stored in the manifest when the theme is published. */
|
|
authorId?: number;
|
|
description: string;
|
|
nsfw: boolean;
|
|
scriptUrl: string;
|
|
cssStyle: string;
|
|
assets: string;
|
|
preview: string;
|
|
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
|
|
progress: number;
|
|
updateAvailable: boolean;
|
|
/** The latest Workshop version, when the theme has been published. */
|
|
workshopVersion?: string;
|
|
/** Hash of the theme files when this copy was last known to match the Workshop. */
|
|
syncedContentHash?: string;
|
|
/** Local version current when the sync baseline was written. */
|
|
syncedVersion?: string;
|
|
/** True when the local theme differs from its published Workshop package. */
|
|
isNotSynced?: boolean;
|
|
/** Modification time of the generated preview, used to migrate existing installs. */
|
|
previewModifiedAt?: number;
|
|
/** Creation time of the theme folder, used to migrate existing installs. */
|
|
folderCreatedAt?: number;
|
|
/** 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;
|
|
workshopId?: string;
|
|
author?: string;
|
|
authorId?: number;
|
|
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<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');
|
|
|
|
const THEME_SYNC_FILENAME = '.workshop-sync.json';
|
|
const CONTENT_HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
|
|
|
export type ThemeWorkshopSync = {
|
|
contentHash: string;
|
|
version?: string;
|
|
workshopId?: string;
|
|
syncedAt?: number;
|
|
};
|
|
|
|
const readThemeWorkshopSyncFile = async (
|
|
folderPath: string
|
|
): Promise<ThemeWorkshopSync | undefined> => {
|
|
const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME);
|
|
if (!(await fs.exists(syncPath))) return undefined;
|
|
|
|
try {
|
|
const parsed = JSON.parse(await fs.readTextFile(syncPath)) as Partial<ThemeWorkshopSync>;
|
|
if (!parsed || typeof parsed !== 'object') return undefined;
|
|
const contentHash =
|
|
typeof parsed.contentHash === 'string' && CONTENT_HASH_PATTERN.test(parsed.contentHash)
|
|
? parsed.contentHash.toLowerCase()
|
|
: undefined;
|
|
if (!contentHash) return undefined;
|
|
|
|
return {
|
|
contentHash,
|
|
version: typeof parsed.version === 'string' ? parsed.version : undefined,
|
|
workshopId: typeof parsed.workshopId === 'string' ? parsed.workshopId : undefined,
|
|
syncedAt: typeof parsed.syncedAt === 'number' ? parsed.syncedAt : undefined,
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
export const readThemeWorkshopSync = async (folderName: string) =>
|
|
readThemeWorkshopSyncFile(await path.join(await themesDirectory(), folderName));
|
|
|
|
export const writeThemeWorkshopSync = async (
|
|
folderName: string,
|
|
sync: ThemeWorkshopSync
|
|
): Promise<void> => {
|
|
const folderPath = await path.join(await themesDirectory(), folderName);
|
|
const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME);
|
|
await fs.writeFile(syncPath, new TextEncoder().encode(`${JSON.stringify(sync, null, 2)}\n`));
|
|
};
|
|
|
|
export const hashThemeContent = async (folderName: string) => {
|
|
const themeFolder = await path.join(await themesDirectory(), folderName);
|
|
return await invoke<string>('hash_theme_content', { themeFolder });
|
|
};
|
|
|
|
/** Record the current local files as the published Workshop baseline. */
|
|
export const markThemeSynced = async (
|
|
folderName: string,
|
|
options: { version?: string; workshopId?: string } = {}
|
|
): Promise<ThemeWorkshopSync> => {
|
|
const contentHash = await hashThemeContent(folderName);
|
|
const sync: ThemeWorkshopSync = {
|
|
contentHash,
|
|
version: options.version,
|
|
workshopId: options.workshopId,
|
|
syncedAt: Date.now(),
|
|
};
|
|
await writeThemeWorkshopSync(folderName, sync);
|
|
return sync;
|
|
};
|
|
|
|
/**
|
|
* 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))
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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<Theme[]> => {
|
|
return [];
|
|
};
|
|
|
|
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 sync = await readThemeWorkshopSyncFile(folderPath);
|
|
const folderStat = await fs.stat(folderPath).catch(() => 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 = '';
|
|
let previewModifiedAt: number | undefined;
|
|
if (previewName) {
|
|
const previewPath = await path.join(folderPath, 'assets', previewName);
|
|
if (await fs.exists(previewPath)) {
|
|
previewModifiedAt = (await fs.stat(previewPath).catch(() => undefined))?.mtime?.getTime();
|
|
// 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,
|
|
workshopId: typeof parsed.workshopId === 'string' ? parsed.workshopId : undefined,
|
|
name: parsed.name,
|
|
version: parsed.version ?? '1.0.0',
|
|
apiVersion,
|
|
author: parsed.author ?? 'Unknown',
|
|
authorId:
|
|
typeof parsed.authorId === 'number' &&
|
|
Number.isSafeInteger(parsed.authorId) &&
|
|
parsed.authorId > 0
|
|
? parsed.authorId
|
|
: undefined,
|
|
description: parsed.description ?? '',
|
|
nsfw: parsed.nsfw === true,
|
|
scriptUrl,
|
|
assets,
|
|
preview,
|
|
status: 'installed',
|
|
progress: 0,
|
|
updateAvailable: false,
|
|
syncedContentHash: sync?.contentHash,
|
|
syncedVersion: sync?.version,
|
|
previewModifiedAt,
|
|
folderCreatedAt: folderStat?.birthtime?.getTime(),
|
|
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({
|
|
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<ThemeBackup[]>('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<DownloadProgress>('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<ExtractProgress>('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<string>('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 };
|
|
};
|
|
|
|
type LoadThemeOptions = {
|
|
/** Crossfade the rendered page when switching themes. */
|
|
animate?: boolean;
|
|
};
|
|
|
|
export const loadTheme = async (
|
|
theme: Theme,
|
|
themeContainer: HTMLElement,
|
|
volume = 0.15,
|
|
options: LoadThemeOptions = {}
|
|
) => {
|
|
const applyTheme = async () => {
|
|
// 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();
|
|
void invoke('unlock_media_autoplay').catch(() => {});
|
|
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<HTMLMediaElement>('video, audio').forEach((media) => {
|
|
media.muted = false;
|
|
media.volume = volume;
|
|
playThemeMedia(media);
|
|
});
|
|
|
|
void invoke('unlock_media_autoplay').catch(() => {});
|
|
};
|
|
|
|
const canAnimate =
|
|
options.animate &&
|
|
typeof document !== 'undefined' &&
|
|
typeof document.startViewTransition === 'function';
|
|
|
|
if (!canAnimate) {
|
|
await applyTheme();
|
|
return;
|
|
}
|
|
|
|
const transition = document.startViewTransition(async () => {
|
|
await applyTheme();
|
|
// Let Svelte paint the new theme before the transition takes its snapshot.
|
|
await tick();
|
|
});
|
|
|
|
// Theme activation must not wait for, or fail because of, the visual
|
|
// transition. `finished` can reject when a transition is skipped.
|
|
void transition.finished.catch(() => {});
|
|
await transition.updateCallbackDone;
|
|
};
|
|
|
|
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<ThemeInfo>('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<ExtractProgress>('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 installedThemes = await getThemes();
|
|
const installedMap = new Map(installedThemes.map((t) => [t.name, t]));
|
|
|
|
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
|
|
.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;
|
|
const versionChanged = storeTheme.version !== theme.version;
|
|
|
|
// 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,
|
|
authorId: theme.authorId,
|
|
apiVersion: theme.apiVersion,
|
|
description: theme.description,
|
|
nsfw: theme.nsfw,
|
|
workshopId: theme.workshopId,
|
|
definition: theme.definition,
|
|
syncedContentHash: theme.syncedContentHash,
|
|
syncedVersion: theme.syncedVersion,
|
|
previewModifiedAt: theme.previewModifiedAt,
|
|
folderCreatedAt: theme.folderCreatedAt,
|
|
isNotSynced: versionChanged ? false : storeTheme.isNotSynced,
|
|
updateAvailable: versionChanged ? false : storeTheme.updateAvailable,
|
|
availableVersion: versionChanged ? undefined : storeTheme.availableVersion,
|
|
cssStyle: theme.cssStyle,
|
|
backups: theme.backups ?? storeTheme.backups,
|
|
};
|
|
});
|
|
|
|
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;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
});
|
|
|
|
active_custom_theme.update((active) => {
|
|
if (!active) return active;
|
|
const updated = installedMap.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;
|
|
};
|
|
|
|
const updateInstalledThemeManifest = async (folderName: string, patch: Record<string, unknown>) => {
|
|
const themeFolder = await path.join(await themesDirectory(), folderName);
|
|
const configPath = await path.join(themeFolder, 'theme.json');
|
|
if (!(await fs.exists(configPath))) {
|
|
throw new Error('The installed theme.json file was not found.');
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(await fs.readTextFile(configPath));
|
|
} catch {
|
|
throw new Error('The installed theme.json file is not valid JSON.');
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('The installed theme.json file is not a valid theme manifest.');
|
|
}
|
|
|
|
const updated = { ...(parsed as Record<string, unknown>), ...patch };
|
|
await fs.writeFile(configPath, new TextEncoder().encode(`${JSON.stringify(updated, null, 2)}\n`));
|
|
};
|
|
|
|
/** Write workshop tags into an installed theme's manifest without dropping other fields. */
|
|
export const updateInstalledThemeTags = async (folderName: string, tags: string[]) =>
|
|
updateInstalledThemeManifest(folderName, { tags });
|
|
|
|
/** Remember the server-assigned workshop ID so later publishes update the same theme. */
|
|
export const updateInstalledThemeWorkshopId = async (folderName: string, workshopId: string) =>
|
|
updateInstalledThemeManifest(folderName, { workshopId });
|
|
|
|
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<ThemeBackup[]>('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, '_')
|
|
);
|
|
};
|