feat: polish theme tooling, diagnostics and launcher workflows

Expand the theme editor with multi-select, snapping, context menus, draft
recovery and export validation; add theme backups and trust prompts. Add
in-app diagnostics and rotating log bundle viewing, hardware-accelerated
theme video playback on macOS, and refresh the settings, setup wizard and
theme gallery layouts.
This commit is contained in:
HorizonCode committed 2026-10-29 15:41:33 +01:00
1 parent 87202de656
commit c02b26e8ae
41 files changed
+5574 -865

No files matched your search

+79 -9
View File
@@ -24,7 +24,17 @@ export type Theme = {
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 = {
@@ -239,11 +249,26 @@ export const getThemes = async (): Promise<Theme[]> => {
if (await fs.exists(folderPath)) {
const themeFolders = await fs.readDir(folderPath);
for (const themeFolder of themeFolders) {
const theme = await themeFromFolder(
themeFolder.name,
await path.join(folderPath, themeFolder.name)
);
if (theme) themes.push(theme);
// 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;
@@ -254,6 +279,7 @@ export const downloadTheme = async (
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' }));
@@ -265,7 +291,10 @@ export const downloadTheme = async (
);
if (themeContents.error) {
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
patchTheme(theme.name, (current) => ({
...current,
status: wasInstalled ? 'installed' : 'not-installed',
}));
return {
success: false,
error: themeContents.error.message ?? 'An unknown error occurred',
@@ -286,6 +315,19 @@ export const downloadTheme = async (
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();
@@ -318,6 +360,16 @@ export const downloadTheme = async (
});
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,
@@ -327,7 +379,11 @@ export const downloadTheme = async (
} catch (err) {
console.log(err);
const erro = err as Error;
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
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();
@@ -349,7 +405,11 @@ export const downloadTheme = async (
const installedTheme = await themeFromFolder(folderName, themeFolder);
if (!installedTheme) {
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
await restoreUpdateBackup();
patchTheme(theme.name, (current) => ({
...current,
status: wasInstalled ? 'installed' : 'not-installed',
}));
return { success: false, error: 'The downloaded theme package is invalid.' };
}
@@ -478,7 +538,9 @@ export const reloadThemes = async () => {
if (installed) {
try {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true;
const installedTheme = combinedMap.get(downloadable.name)!;
installedTheme.updateAvailable = true;
installedTheme.availableVersion = downloadable.version;
}
} catch {
// Ignore malformed versions instead of breaking the whole theme list.
@@ -513,7 +575,9 @@ export const reloadThemes = async () => {
nsfw: theme.nsfw,
definition: theme.definition,
updateAvailable: theme.updateAvailable,
availableVersion: theme.availableVersion,
cssStyle: theme.cssStyle,
backups: theme.backups ?? storeTheme.backups,
};
});
@@ -555,6 +619,12 @@ export const exportThemeFolder = async (folderName: string, destinationPath: str
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);