feat(themes): add revert for unsynced themes

Restore the published Workshop version after confirmation, keeping a backup of
local edits. Owner reverts send auth headers so themes-service skips the
download counter.
This commit is contained in:
HorizonCode committed 2026-11-04 14:50:03 +01:00
1 parent d4be3f845d
commit ef214d424c
3 files changed
+169 -43

No files matched your search

+12 -7
View File
@@ -3,7 +3,7 @@ use hardware_id::get_id;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
#[cfg(not(target_os = "macos"))]
@@ -1688,14 +1688,19 @@ pub async fn download_and_extract_community_theme(
download_url: String,
theme_folder: String,
theme_name: String,
request_headers: Option<HashMap<String, String>>,
) -> Result<(), String> {
let client = reqwest::Client::new();
let response = client
.get(&download_url)
.header(
"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",
)
let mut request = client.get(&download_url).header(
"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 let Some(headers) = request_headers {
for (name, value) in headers {
request = request.header(name.as_str(), value.as_str());
}
}
let response = request
.send()
.await
.map_err(|e| format!("Failed to download theme: {}", e))?;
+18 -2
View File
@@ -953,12 +953,27 @@ export async function reviewCommunityTheme(
export async function downloadAndInstallCommunityTheme(
theme: CommunityTheme,
onProgress?: (status: string, percentage: number) => void,
options?: { folderName?: string }
options?: { folderName?: string; skipDownloadCount?: boolean }
): Promise<{ success: boolean; folderName: string }> {
const { onProgress: reportProgress = () => {} } = { onProgress };
const baseThemesDir = await themesDirectory();
const folderName = options?.folderName?.trim() || theme.id;
const targetFolder = await path.join(baseThemesDir, folderName);
const skipDownloadCount = options?.skipDownloadCount === true;
const downloadUrl = skipDownloadCount
? (() => {
const url = new URL(theme.downloadUrl);
url.searchParams.set('skipDownloadCount', '1');
return url.toString();
})()
: theme.downloadUrl;
let requestHeaders: Record<string, string> | undefined;
if (skipDownloadCount) {
requestHeaders = await getAuthHeaders();
if (!requestHeaders['X-Username'] || !requestHeaders['X-Password']) {
throw new Error('Please log in again before reverting this theme.');
}
}
reportProgress('Downloading theme package...', 10);
@@ -987,9 +1002,10 @@ export async function downloadAndInstallCommunityTheme(
try {
try {
await invoke('download_and_extract_community_theme', {
downloadUrl: theme.downloadUrl,
downloadUrl,
themeFolder: targetFolder,
themeName: theme.name,
...(requestHeaders ? { requestHeaders } : {}),
});
} catch (err) {
// Fallback for legacy themes directly to git repo
+139 -34
View File
@@ -315,6 +315,7 @@
let restoringTheme = $state(false);
let uninstallCandidate = $state<Theme | undefined>(undefined);
let uninstallingTheme = $state(false);
let revertCandidate = $state<Theme | undefined>(undefined);
let installedReportOpen = $state(false);
let installedReportTarget = $state<CommunityTheme | null>(null);
let installedReportLoading = $state(false);
@@ -768,8 +769,8 @@
}
};
const updateInstalledTheme = async (theme: Theme) => {
if (theme.status !== 'installed' || updatingThemeFolder) return;
const updateInstalledTheme = async (theme: Theme, mode: 'update' | 'revert' = 'update') => {
if (theme.status !== 'installed' || updatingThemeFolder) return false;
const wasActive = $active_custom_theme?.folder_name === theme.folder_name;
updatingThemeFolder = theme.folder_name;
@@ -785,12 +786,19 @@
try {
backup = await invoke<string>('backup_theme_folder', { themeFolder });
} catch (error) {
if (mode === 'revert') {
throw new Error(
'Could not back up the current theme before reverting. Your local changes were left untouched.',
{ cause: error }
);
}
console.warn('Could not back up the theme before updating it', error);
}
try {
await downloadAndInstallCommunityTheme(target, () => {}, {
folderName: theme.folder_name,
skipDownloadCount: mode === 'revert',
});
} catch (error) {
if (backup) {
@@ -820,21 +828,40 @@
themeContentHashCache.delete(theme.folder_name);
communityThemeCache.delete(theme.folder_name);
await refreshThemeSyncState(theme.folder_name);
sileo.success({
title: 'Yaay!',
description: 'Theme updated successfully!',
title: mode === 'revert' ? 'Theme reverted' : 'Yaay!',
description:
mode === 'revert'
? `"${theme.name}" was restored to the published Workshop version.`
: 'Theme updated successfully!',
});
return true;
} catch (error) {
console.error(error);
sileo.error({
title: 'Could not update theme',
title: mode === 'revert' ? 'Could not revert theme' : 'Could not update theme',
description: error instanceof Error ? error.message : String(error),
});
return false;
} finally {
updatingThemeFolder = undefined;
}
};
const requestThemeRevert = (theme: Theme) => {
if (theme.status !== 'installed' || !theme.workshopId || updatingThemeFolder) return;
revertCandidate = theme;
};
const confirmThemeRevert = async () => {
const theme = revertCandidate;
if (!theme) return;
if (await updateInstalledTheme(theme, 'revert')) {
revertCandidate = undefined;
}
};
$effect(() => {
const userId = $currentUser?.id;
const themeCount = $custom_themes.length;
@@ -2427,29 +2454,40 @@
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={uninstallCandidate !== undefined}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0 max-w-xl"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center gap-2 border-b border-theme-800 bg-black/40 rounded-t-lg p-4"
>
<ThemeIcon name="trash" set={iconSet} class="size-7 text-red-300" />
<span class="font-semibold text-lg">Uninstall theme?</span>
</div>
<div
class="mx-3 mt-3 rounded-lg border border-theme-800 bg-theme-900 p-3 text-xs leading-relaxed text-muted-foreground"
>
Remove <strong class="text-theme-100">{uninstallCandidate?.name}</strong> from your library? This
deletes the local theme files and any unpublished changes. If the theme is published to the Workshop,
that copy is not affected.
</div>
<div class="flex items-center justify-center gap-3 p-4">
<AlertDialog.Root
open={uninstallCandidate !== undefined}
onOpenChange={(open) => {
if (!open && !uninstallingTheme) uninstallCandidate = undefined;
}}
>
<AlertDialog.Content class="max-w-md border-theme-800 bg-theme-950 text-white">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2 text-base">
<ThemeIcon name="trash" set={iconSet} class="size-4 text-red-400" />
Uninstall theme?
</AlertDialog.Title>
<AlertDialog.Description class="text-[11px] leading-relaxed text-muted-foreground">
Remove <span class="font-medium text-theme-100">{uninstallCandidate?.name}</span> from your library?
This deletes the local theme files from this computer.
</AlertDialog.Description>
</AlertDialog.Header>
{#if uninstallCandidate && uninstallCandidate.workshopId && isOwnInstalledTheme(uninstallCandidate)}
<p
class="flex items-start gap-2 rounded-md border border-theme-800 bg-theme-900/50 p-2.5 text-[11px] leading-relaxed text-muted-foreground"
>
<Globe class="mt-0.5 size-3.5 shrink-0 text-cyan-300" />
<span>
Your published Workshop copy stays online. Only this local copy and any unpublished
changes are removed.
</span>
</p>
{/if}
<AlertDialog.Footer class="gap-2">
<Button
variant="outline"
class="min-w-28"
variant="ghost"
size="sm"
disabled={uninstallingTheme}
onclick={() => (uninstallCandidate = undefined)}
>
@@ -2457,18 +2495,66 @@
</Button>
<Button
variant="destructive"
class="min-w-28"
size="sm"
class="gap-1.5"
disabled={uninstallingTheme}
onclick={confirmThemeUninstall}
>
{#if uninstallingTheme}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" />
<ThemeIcon name="loader" set={iconSet} class="size-3.5 animate-spin" />
Uninstalling...
{:else}
<ThemeIcon name="trash" set={iconSet} class="size-4" />
<ThemeIcon name="trash" set={iconSet} class="size-3.5" />
Uninstall theme
{/if}
{uninstallingTheme ? 'Uninstalling...' : 'Uninstall theme'}
</Button>
</div>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root
open={revertCandidate !== undefined}
onOpenChange={(open) => {
if (!open && updatingThemeFolder !== revertCandidate?.folder_name) revertCandidate = undefined;
}}
>
<AlertDialog.Content class="max-w-md border-theme-800 bg-theme-950 text-white">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2 text-base">
<ThemeIcon name="rotate" set={iconSet} class="size-4 text-amber-300" />
Revert unsynced changes?
</AlertDialog.Title>
<AlertDialog.Description class="text-[11px] leading-relaxed text-muted-foreground">
Replace the local files for <span class="font-medium text-theme-100"
>{revertCandidate?.name}</span
> with the published Workshop version? Any unpublished changes will be removed. A backup of the
current local copy will be created first.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer class="gap-2">
<Button
variant="ghost"
size="sm"
disabled={updatingThemeFolder === revertCandidate?.folder_name}
onclick={() => (revertCandidate = undefined)}
>
Cancel
</Button>
<Button
size="sm"
class="gap-1.5"
disabled={updatingThemeFolder === revertCandidate?.folder_name}
onclick={confirmThemeRevert}
>
{#if updatingThemeFolder === revertCandidate?.folder_name}
<ThemeIcon name="loader" set={iconSet} class="size-3.5 animate-spin" />
Reverting...
{:else}
<ThemeIcon name="rotate" set={iconSet} class="size-3.5" />
Revert changes
{/if}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -4343,6 +4429,10 @@
workshopVersion !== localVersion}
{@const isNotSynced =
isOwnTheme && (theme.isNotSynced === true || versionMismatch)}
{@const canRevertTheme =
isNotSynced &&
theme.status === 'installed' &&
Boolean(theme.workshopId)}
{@const canReportTheme =
Boolean($currentUser && ownedWorkshopLoaded) &&
!isOwnTheme &&
@@ -4357,6 +4447,7 @@
canEditTheme ||
canRestoreTheme ||
canUpdateTheme ||
canRevertTheme ||
canReportTheme ||
canUninstallTheme}
<div
@@ -4633,7 +4724,7 @@
Restore previous version
</DropdownMenu.Item>
{/if}
{#if canUpdateTheme}
{#if canUpdateTheme && !canRevertTheme}
<DropdownMenu.Item
class="gap-2 text-[11px]"
disabled={updatingThemeFolder === theme.folder_name}
@@ -4647,6 +4738,20 @@
Update theme
</DropdownMenu.Item>
{/if}
{#if canRevertTheme}
<DropdownMenu.Item
class="gap-2 text-[11px] text-amber-200 data-[highlighted]:bg-amber-500/10 data-[highlighted]:text-amber-100"
disabled={updatingThemeFolder === theme.folder_name}
onclick={() => requestThemeRevert(theme)}
>
<ThemeIcon
name="rotate"
set={iconSet}
class="size-3.5"
/>
Revert unsynced changes
</DropdownMenu.Item>
{/if}
{#if canReportTheme}
<DropdownMenu.Item
class="gap-2 text-[11px]"
@@ -4658,7 +4763,7 @@
</DropdownMenu.Item>
{/if}
{#if canUninstallTheme}
{#if canEditTheme || canRestoreTheme || canUpdateTheme || canReportTheme}
{#if canEditTheme || canRestoreTheme || canUpdateTheme || canRevertTheme || canReportTheme}
<DropdownMenu.Separator class="my-1 bg-theme-800" />
{/if}
<DropdownMenu.Item