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
+166 -40

No files matched your search

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