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:
1 parent
d4be3f845d
commit
ef214d424c
3 files changed
+166
-40
No files matched your search
@@ -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))?;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user