664 lines
21 KiB
TypeScript
664 lines
21 KiB
TypeScript
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<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[] = [];
|
|
|
|
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<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({
|
|
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 };
|
|
};
|
|
|
|
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<HTMLMediaElement>('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<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 [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<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, '_')
|
|
);
|
|
};
|
|
|
|
const generateRandomString = (length: number) => {
|
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
return Array.from({ length }, () =>
|
|
characters.charAt(Math.floor(Math.random() * characters.length))
|
|
).join('');
|
|
};
|