diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4db3b1d..d79b7c1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -195,6 +195,26 @@ } ] }, + { + "identifier": "fs:allow-stat", + "allow": [ + { + "path": "$HOME/.ezpplauncher" + }, + { + "path": "$HOME/.ezpplauncher/*" + }, + { + "path": "$HOME/.ezpplauncher/**/*" + }, + { + "path": "$HOME/.ezpplauncher/**/.*" + }, + { + "path": "$HOME/.ezpplauncher/**/.*/**" + } + ] + }, { "identifier": "fs:allow-mkdir", "allow": [ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b15fb3f..1cbcda3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -112,6 +112,136 @@ pub async fn hash_file_sha256(path: String) -> Result { .map_err(|e| format!("Thread error: {e}"))? } +/// Hash the files that make up a theme package. This ignores archive metadata +/// and the local workshop-sync sidecar, so it can be compared before and after +/// editing the theme folder. +#[tauri::command] +pub async fn hash_theme_content(theme_folder: String) -> Result { + use sha2::{Digest, Sha256}; + + let folder = checked_theme_folder(&theme_folder)?; + tokio::task::spawn_blocking(move || { + let asset_filter = referenced_theme_assets(&folder.join("theme.json")); + let mut files = Vec::new(); + collect_theme_content_files(&folder, &folder, asset_filter.as_ref(), &mut files)?; + files.sort_by_key(|path| path.to_string_lossy().to_lowercase()); + + let mut hasher = Sha256::new(); + hasher.update(b"ezpplauncher-theme-content-v1\0"); + + for relative in files { + let path = folder.join(&relative); + let metadata = std::fs::metadata(&path) + .map_err(|e| format!("Failed to inspect theme file {}: {e}", path.display()))?; + if !metadata.is_file() { + continue; + } + + let name = relative.to_string_lossy().replace('\\', "/"); + hasher.update((name.len() as u64).to_le_bytes()); + hasher.update(name.as_bytes()); + + // Workshop metadata is not theme content. Ignore it so syncing + // tags or the server-assigned ID does not look like a local edit. + if name == "theme.json" { + let text = std::fs::read_to_string(&path).map_err(|e| { + format!("Failed to read theme manifest {}: {e}", path.display()) + })?; + let mut config: Value = serde_json::from_str(&text) + .map_err(|e| format!("Failed to parse theme manifest: {e}"))?; + if let Some(object) = config.as_object_mut() { + object.remove("workshopId"); + object.remove("tags"); + } + let bytes = serde_json::to_vec(&config) + .map_err(|e| format!("Failed to serialize theme manifest: {e}"))?; + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(&bytes); + continue; + } + + hasher.update(metadata.len().to_le_bytes()); + let mut file = std::fs::File::open(&path) + .map_err(|e| format!("Failed to open theme file {}: {e}", path.display()))?; + let mut buffer = [0u8; 65536]; + loop { + let bytes_read = file + .read(&mut buffer) + .map_err(|e| format!("Failed to read theme file {}: {e}", path.display()))?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + } + + Ok::<_, String>(hex::encode(hasher.finalize())) + }) + .await + .map_err(|e| format!("Thread error: {e}"))? +} + +fn collect_theme_content_files( + root: &Path, + directory: &Path, + asset_filter: Option<&HashSet>, + files: &mut Vec, +) -> Result<(), String> { + let entries = std::fs::read_dir(directory) + .map_err(|e| format!("Failed to read {}: {e}", directory.display()))?; + let assets_root = root.join("assets"); + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?; + let file_name = entry.file_name(); + if file_name.to_string_lossy().starts_with('.') { + continue; + } + + let file_type = entry + .file_type() + .map_err(|e| format!("Failed to inspect theme entry: {e}"))?; + if file_type.is_symlink() { + continue; + } + + let entry_path = entry.path(); + let relative = entry_path + .strip_prefix(root) + .map_err(|e| format!("Failed to build theme path: {e}"))? + .to_string_lossy() + .replace('\\', "/"); + + if file_type.is_dir() { + if asset_filter.is_some() && directory == root && relative != "assets" { + continue; + } + collect_theme_content_files(root, &entry_path, asset_filter, files)?; + continue; + } + if !file_type.is_file() { + continue; + } + + if asset_filter.is_some() && directory == root && relative != "theme.json" { + continue; + } + + if let Some(filter) = asset_filter + && let Ok(asset_path) = entry_path.strip_prefix(&assets_root) + { + let key = asset_path.to_string_lossy().replace('\\', "/"); + if !filter.contains(&key) { + continue; + } + } + + files.push(PathBuf::from(relative)); + } + + Ok(()) +} + /// Read a chunk of a file on disk starting at `offset` for `length` bytes. #[tauri::command] pub async fn read_file_chunk(path: String, offset: u64, length: u64) -> Result, String> { @@ -3080,6 +3210,9 @@ fn add_theme_folder_to_zip( for entry in entries { let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?; + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } let file_type = entry .file_type() .map_err(|e| format!("Failed to inspect theme entry: {e}"))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e530a5..8d6612c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,8 +23,9 @@ use crate::{ download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit, export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream, - get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello, - has_wmctrl, hash_file_sha256, install_ezpp_launcher_update, installation_diagnostics, + get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, + has_osuwinello, has_wmctrl, hash_file_sha256, hash_theme_content, + install_ezpp_launcher_update, installation_diagnostics, is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running, list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder, open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation, @@ -196,6 +197,7 @@ pub fn run() { save_theme_project, export_theme, hash_file_sha256, + hash_theme_content, read_file_chunk, open_theme_folder, open_theme_editor, diff --git a/src/lib/communityThemes.ts b/src/lib/communityThemes.ts index 074498e..1e264bb 100644 --- a/src/lib/communityThemes.ts +++ b/src/lib/communityThemes.ts @@ -1,6 +1,7 @@ import { config } from './config'; import { normalizeThemeVersion } from './themeV2'; import { + markThemeSynced, reloadThemes, themesDirectory, updateInstalledThemeTags, @@ -1008,6 +1009,7 @@ export async function downloadAndInstallCommunityTheme( // Keep the installed manifest aligned with the workshop record. await updateInstalledThemeTags(folderName, theme.tags); await updateInstalledThemeWorkshopId(folderName, theme.id); + await markThemeSynced(folderName, { workshopId: theme.id, version: theme.version }); } catch (err) { console.warn('[Workshop] Could not write workshop metadata into the installed theme:', err); } diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index 089c0e9..e8aa517 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -59,6 +59,7 @@ themeGroupContainsElement, themeGroupInteractiveCount, } from '$lib/themeLogic'; + import { themeParallax } from '$lib/themeParallax'; import { renderThemePreview } from '$lib/themePreview'; import { themeScriptInitialVariables, themeScriptVariableNames } from '$lib/themeScript'; import { @@ -98,6 +99,7 @@ } from '$lib/themeV2'; import { exportThemeFolder, + markThemeSynced, saveThemeProject, themeAssetBase, themesDirectory, @@ -3545,9 +3547,13 @@ publishThemeId = result.themeId; try { await updateInstalledThemeWorkshopId(savedFolder, result.themeId); + await markThemeSynced(savedFolder, { + workshopId: result.themeId, + version: versionCheck.version, + }); lastSavedKey = snapshotKey(captureSnapshot()); } catch (err) { - console.warn('[Workshop] Could not store the theme ID in theme.json:', err); + console.warn('[Workshop] Could not store the theme sync metadata:', err); } // The launcher window owns the installed-theme list. Tell it about the @@ -6131,26 +6137,34 @@
- selectElement(id, additive)} - onselectmany={(ids, additive) => selectMany(ids, additive)} - onselectgroup={(id) => selectGroup(id)} - onmove={moveElements} - onpatch={(id, patch) => patchElement(id, patch)} - onpatchgroup={(id, patch) => patchGroup(id, patch)} - oncontext={(id) => prepareContextMenu(id)} - oncontextgroup={(id) => prepareGroupContextMenu(id)} - ondetach={handleDetachWidget} - onbuttonaction={handleTestButtonAction} - /> +
+ selectElement(id, additive)} + onselectmany={(ids, additive) => selectMany(ids, additive)} + onselectgroup={(id) => selectGroup(id)} + onmove={moveElements} + onpatch={(id, patch) => patchElement(id, patch)} + onpatchgroup={(id, patch) => patchGroup(id, patch)} + oncontext={(id) => prepareContextMenu(id)} + oncontextgroup={(id) => prepareGroupContextMenu(id)} + ondetach={handleDetachWidget} + onbuttonaction={handleTestButtonAction} + /> +
diff --git a/src/lib/components/theme/ThemeTransformControls.svelte b/src/lib/components/theme/ThemeTransformControls.svelte index 460faa5..9006b5e 100644 --- a/src/lib/components/theme/ThemeTransformControls.svelte +++ b/src/lib/components/theme/ThemeTransformControls.svelte @@ -3,6 +3,7 @@ import Button from '$lib/components/ui/button/button.svelte'; import Input from '$lib/components/ui/input/input.svelte'; import Label from '$lib/components/ui/label/label.svelte'; + import Slider from '$lib/components/ui/slider/slider.svelte'; import type { ThemeV2Transform } from '$lib/themeV2'; import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte'; @@ -267,14 +268,13 @@ 3D cursor perspective
- onchange({ parallax: Number((e.target as HTMLInputElement).value) })} - class="h-1.5 w-24 cursor-pointer appearance-none rounded-lg bg-theme-800 accent-primary" + onValueChange={(value) => onchange({ parallax: value })} + class="w-24" /> {Math.round((transform.parallax ?? 0) * 100)}% diff --git a/src/lib/components/theme/ThemeV2Background.svelte b/src/lib/components/theme/ThemeV2Background.svelte index 57ef18a..7e58006 100644 --- a/src/lib/components/theme/ThemeV2Background.svelte +++ b/src/lib/components/theme/ThemeV2Background.svelte @@ -1,6 +1,7 @@ - - -
- {#if background.type === 'color'} -
- {:else if background.type === 'gradient'} -
- {:else if background.type === 'image'} - {#if background.src} - +
+
+ {#if background.type === 'color'} +
+ {:else if background.type === 'gradient'} +
+ {:else if background.type === 'image'} + {#if background.src} + + {/if} + {:else if background.type === 'video'} + {#if background.src} + + {/if} {/if} - {:else if background.type === 'video'} - {#if background.src} - - {/if} - {/if} +
{#if overlay && (overlay.opacity ?? 0) > 0}
themeFilterCss(element.filters, ...ancestorsOf(element).map((group) => group.filters)); + /** Layer depth plus every ancestor group's depth; negative follows the background. */ + const elementParallaxCss = (element: ThemeV2Element) => { + const depth = + (element.parallax ?? 0) + + ancestorsOf(element).reduce((total, group) => total + (group.parallax ?? 0), 0); + if (depth === 0) return ''; + const offset = depth * 30; + return ` translate3d(calc(var(--mouse-px, 0) * ${offset}px), calc(var(--mouse-py, 0) * ${offset}px), 0)`; + }; const elementClassNames = (element: ThemeV2Element) => themeClassNames( [...ancestorsOf(element).map((group) => group.cssClasses), element.cssClasses] @@ -1227,9 +1236,7 @@ element, elements, groups - )}{element.parallax - ? ` translate3d(calc(var(--mouse-px, 0) * ${element.parallax * 30}px), calc(var(--mouse-py, 0) * ${element.parallax * 30}px), 0)` - : ''};transform-origin:0 0;touch-action:none;outline:{selected + )}{elementParallaxCss(element)};transform-origin:0 0;touch-action:none;outline:{selected ? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e` : 'none'};outline-offset:2px;" role={interactive || element.interactive ? 'button' : undefined} diff --git a/src/lib/themeParallax.ts b/src/lib/themeParallax.ts new file mode 100644 index 0000000..4dd2b53 --- /dev/null +++ b/src/lib/themeParallax.ts @@ -0,0 +1,101 @@ +import { reduceAnimations } from '$lib/userSettings'; +import { get } from 'svelte/store'; + +export type ThemeParallaxOptions = { + enabled?: boolean; + intensity?: number; +}; + +const clampIntensity = (intensity: number | undefined) => + Math.min(1, Math.max(0.05, typeof intensity === 'number' ? intensity : 0.25)); + +/** + * Tracks the pointer for a theme host and exposes the smoothed offset as + * `--mouse-px` / `--mouse-py`. The host must contain both the background and + * the freeform layers so those variables are inherited by every layer. + */ +export function themeParallax(node: HTMLElement, options: ThemeParallaxOptions = {}) { + let enabled = options.enabled === true; + let intensity = clampIntensity(options.intensity); + let mousePx = 0; + let mousePy = 0; + let targetPx = 0; + let targetPy = 0; + let animFrame: number | undefined; + let reduced = get(reduceAnimations); + + function apply() { + node.style.setProperty('--mouse-px', mousePx.toFixed(4)); + node.style.setProperty('--mouse-py', mousePy.toFixed(4)); + } + + function stop() { + if (animFrame !== undefined) { + cancelAnimationFrame(animFrame); + animFrame = undefined; + } + } + + function reset() { + stop(); + mousePx = 0; + mousePy = 0; + targetPx = 0; + targetPy = 0; + apply(); + } + + function updateParallax() { + animFrame = undefined; + const dx = targetPx - mousePx; + const dy = targetPy - mousePy; + + if (Math.abs(dx) < 0.0005 && Math.abs(dy) < 0.0005) { + mousePx = targetPx; + mousePy = targetPy; + apply(); + return; + } + + mousePx += dx * 0.12; + mousePy += dy * 0.12; + apply(); + animFrame = requestAnimationFrame(updateParallax); + } + + function onWindowMouseMove(event: MouseEvent) { + if (!enabled || reduced) return; + + const width = window.innerWidth || 1000; + const height = window.innerHeight || 650; + targetPx = (event.clientX / width - 0.5) * 2 * intensity; + targetPy = (event.clientY / height - 0.5) * 2 * intensity; + + if (animFrame === undefined) { + animFrame = requestAnimationFrame(updateParallax); + } + } + + const unsubscribe = reduceAnimations.subscribe((value) => { + reduced = value; + if (reduced) reset(); + }); + + window.addEventListener('mousemove', onWindowMouseMove); + apply(); + + return { + update(next: ThemeParallaxOptions = {}) { + enabled = next.enabled === true; + intensity = clampIntensity(next.intensity); + if (!enabled || reduced) reset(); + }, + destroy() { + unsubscribe(); + window.removeEventListener('mousemove', onWindowMouseMove); + stop(); + node.style.removeProperty('--mouse-px'); + node.style.removeProperty('--mouse-py'); + }, + }; +} diff --git a/src/lib/themes.ts b/src/lib/themes.ts index a57e890..e4ae796 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -27,6 +27,18 @@ export type Theme = { status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed'; progress: number; updateAvailable: boolean; + /** The latest Workshop version, when the theme has been published. */ + workshopVersion?: string; + /** Hash of the theme files when this copy was last known to match the Workshop. */ + syncedContentHash?: string; + /** Local version current when the sync baseline was written. */ + syncedVersion?: string; + /** True when the local theme differs from its published Workshop package. */ + isNotSynced?: boolean; + /** Modification time of the generated preview, used to migrate existing installs. */ + previewModifiedAt?: number; + /** Creation time of the theme folder, used to migrate existing installs. */ + folderCreatedAt?: number; /** The version available in the repository when an update is pending. */ availableVersion?: string; definition?: ThemeV2Definition; @@ -95,6 +107,75 @@ const encodeThemePath = (value: string) => export const themesDirectory = async () => await path.join(await path.homeDir(), '.ezpplauncher', 'themes'); +const THEME_SYNC_FILENAME = '.workshop-sync.json'; +const CONTENT_HASH_PATTERN = /^[a-f0-9]{64}$/i; + +export type ThemeWorkshopSync = { + contentHash: string; + version?: string; + workshopId?: string; + syncedAt?: number; +}; + +const readThemeWorkshopSyncFile = async ( + folderPath: string +): Promise => { + const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME); + if (!(await fs.exists(syncPath))) return undefined; + + try { + const parsed = JSON.parse(await fs.readTextFile(syncPath)) as Partial; + if (!parsed || typeof parsed !== 'object') return undefined; + const contentHash = + typeof parsed.contentHash === 'string' && CONTENT_HASH_PATTERN.test(parsed.contentHash) + ? parsed.contentHash.toLowerCase() + : undefined; + if (!contentHash) return undefined; + + return { + contentHash, + version: typeof parsed.version === 'string' ? parsed.version : undefined, + workshopId: typeof parsed.workshopId === 'string' ? parsed.workshopId : undefined, + syncedAt: typeof parsed.syncedAt === 'number' ? parsed.syncedAt : undefined, + }; + } catch { + return undefined; + } +}; + +export const readThemeWorkshopSync = async (folderName: string) => + readThemeWorkshopSyncFile(await path.join(await themesDirectory(), folderName)); + +export const writeThemeWorkshopSync = async ( + folderName: string, + sync: ThemeWorkshopSync +): Promise => { + const folderPath = await path.join(await themesDirectory(), folderName); + const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME); + await fs.writeFile(syncPath, new TextEncoder().encode(`${JSON.stringify(sync, null, 2)}\n`)); +}; + +export const hashThemeContent = async (folderName: string) => { + const themeFolder = await path.join(await themesDirectory(), folderName); + return await invoke('hash_theme_content', { themeFolder }); +}; + +/** Record the current local files as the published Workshop baseline. */ +export const markThemeSynced = async ( + folderName: string, + options: { version?: string; workshopId?: string } = {} +): Promise => { + const contentHash = await hashThemeContent(folderName); + const sync: ThemeWorkshopSync = { + contentHash, + version: options.version, + workshopId: options.workshopId, + syncedAt: Date.now(), + }; + await writeThemeWorkshopSync(folderName, sync); + return sync; +}; + /** * 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 @@ -132,6 +213,8 @@ const themeFromFolder = async ( const definition = isV2 ? normalizeThemeV2(parsed as unknown as Partial) : undefined; + const sync = await readThemeWorkshopSyncFile(folderPath); + const folderStat = await fs.stat(folderPath).catch(() => undefined); const httpBase = await themeAssetBase(); const httpFolder = httpBase ? `${httpBase}/${encodeURIComponent(folderName)}` : ''; @@ -158,9 +241,11 @@ const themeFromFolder = async ( : convertFileSrc(await path.normalize(await path.join(folderPath, 'assets'))); const previewName = parsed.preview || (isV2 ? 'preview.png' : ''); let preview = ''; + let previewModifiedAt: number | undefined; if (previewName) { const previewPath = await path.join(folderPath, 'assets', previewName); if (await fs.exists(previewPath)) { + previewModifiedAt = (await fs.stat(previewPath).catch(() => undefined))?.mtime?.getTime(); // 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 @@ -190,6 +275,10 @@ const themeFromFolder = async ( status: 'installed', progress: 0, updateAvailable: false, + syncedContentHash: sync?.contentHash, + syncedVersion: sync?.version, + previewModifiedAt, + folderCreatedAt: folderStat?.birthtime?.getTime(), cssStyle, definition, }; @@ -539,6 +628,11 @@ export const reloadThemes = async () => { nsfw: theme.nsfw, workshopId: theme.workshopId, definition: theme.definition, + syncedContentHash: theme.syncedContentHash, + syncedVersion: theme.syncedVersion, + previewModifiedAt: theme.previewModifiedAt, + folderCreatedAt: theme.folderCreatedAt, + isNotSynced: versionChanged ? false : storeTheme.isNotSynced, updateAvailable: versionChanged ? false : storeTheme.updateAvailable, availableVersion: versionChanged ? undefined : storeTheme.availableVersion, cssStyle: theme.cssStyle, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index fbd7756..959ecbc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -43,6 +43,7 @@ themeMediaSuspended, themeMediaSuspension, } from '$lib/themeMedia'; + import { themeParallax } from '$lib/themeParallax'; import { isThemeTrusted } from '$lib/themeTrust'; import { themeV2Css } from '$lib/themeV2'; import { getThemes, loadTheme } from '$lib/themes'; @@ -550,7 +551,13 @@ -
+
diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index c555645..ab98de4 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -122,8 +122,10 @@ checkThemeFromFile, deleteTheme, downloadTheme, + hashThemeContent, importThemeFromFile, loadTheme, + markThemeSynced, openThemesFolder, reloadThemes, restoreThemeBackup, @@ -317,6 +319,7 @@ let ownedWorkshopThemeIds = $state([]); let ownedWorkshopThemeNames = $state([]); let ownedWorkshopLoaded = $state(false); + const themeContentHashCache = new SvelteMap(); let ownedThemesRequestId = 0; let logViewerOpen = $state(false); let presenceEditorOpen = $state(false); @@ -326,6 +329,9 @@ const communityThemeCache = new SvelteMap(); let lastThemeUpdateCheck = 0; + let lastSyncUserId: number | undefined; + let lastSyncThemeCount = -1; + let lastSyncOwnedCount = -1; const themeFilterOptions = [ { id: 'all', label: 'All themes', icon: 'all-themes' }, @@ -410,6 +416,7 @@ const isOwnInstalledTheme = (theme: Theme) => { if (!$currentUser) return false; + if (theme.authorId === $currentUser.id) return true; const folderName = theme.folder_name.trim().toLowerCase(); const workshopId = theme.workshopId?.trim().toLowerCase(); @@ -543,6 +550,117 @@ } }; + const getThemeContentHash = async (theme: Theme) => { + const cached = themeContentHashCache.get(theme.folder_name); + if (cached) return cached; + const hash = await hashThemeContent(theme.folder_name); + themeContentHashCache.set(theme.folder_name, hash); + return hash; + }; + + /** + * Themes installed before content hashes were introduced have no baseline. + * Their preview is regenerated on every editor save, so a preview newer than + * the Workshop record means the local copy was edited after publication. + * The folder creation check keeps fresh downloads from being flagged. + */ + const legacyThemeChangedAfterPublish = (theme: Theme, remote: CommunityTheme) => { + const publishedAt = Date.parse(remote.updatedAt); + if (!Number.isFinite(publishedAt)) return false; + + const createdAt = theme.folderCreatedAt; + const previewModifiedAt = theme.previewModifiedAt; + return ( + createdAt !== undefined && + previewModifiedAt !== undefined && + createdAt < publishedAt && + previewModifiedAt > publishedAt + 1_000 + ); + }; + + type ThemeSyncState = { + syncedContentHash?: string; + syncedVersion?: string; + isNotSynced: boolean; + }; + + const resolveThemeSyncState = async ( + theme: Theme, + remote: CommunityTheme, + localVersion: string + ): Promise => { + const versionMismatch = remote.version !== localVersion; + let contentHash: string | undefined; + try { + contentHash = await getThemeContentHash(theme); + } catch (error) { + console.warn(`[Themes] Could not hash "${theme.name}":`, error); + } + + if (theme.syncedContentHash) { + const contentMismatch = contentHash !== undefined && contentHash !== theme.syncedContentHash; + return { + syncedContentHash: theme.syncedContentHash, + syncedVersion: theme.syncedVersion, + isNotSynced: + versionMismatch || + contentMismatch || + (theme.syncedVersion !== undefined && theme.syncedVersion !== localVersion), + }; + } + + if (versionMismatch || legacyThemeChangedAfterPublish(theme, remote)) { + return { isNotSynced: true }; + } + + try { + const sync = await markThemeSynced(theme.folder_name, { + workshopId: remote.id, + version: localVersion, + }); + themeContentHashCache.set(theme.folder_name, sync.contentHash); + return { + syncedContentHash: sync.contentHash, + syncedVersion: sync.version, + isNotSynced: false, + }; + } catch (error) { + console.warn(`[Themes] Could not record sync state for "${theme.name}":`, error); + return { isNotSynced: false }; + } + }; + + const refreshThemeSyncState = async (folderName: string) => { + themeContentHashCache.delete(folderName); + const theme = get(custom_themes).find((candidate) => candidate.folder_name === folderName); + if (!theme || !theme.workshopId) return; + + let contentHash: string; + try { + contentHash = await getThemeContentHash(theme); + } catch (error) { + console.warn(`[Themes] Could not hash "${theme.name}" after saving:`, error); + return; + } + + const localVersion = normalizeThemeVersion(theme.version); + const workshopVersion = theme.workshopVersion + ? normalizeThemeVersion(theme.workshopVersion) + : undefined; + const baseline = theme.syncedContentHash; + const isNotSynced = baseline + ? baseline !== contentHash || + (theme.syncedVersion !== undefined && theme.syncedVersion !== localVersion) || + (workshopVersion !== undefined && localVersion !== workshopVersion) + : true; + + custom_themes.update((themes) => + themes.map((candidate) => + candidate.folder_name === folderName ? { ...candidate, isNotSynced } : candidate + ) + ); + }; + const checkInstalledThemeUpdates = async (force = false) => { if (checkingThemeUpdates) return; const now = Date.now(); @@ -590,6 +708,20 @@ const workers = Math.min(4, installed.length); await Promise.all(Array.from({ length: workers }, () => checkNext())); + const syncStates = new SvelteMap(); + for (const theme of installed) { + const remote = remoteThemes.get(theme.folder_name); + if (!remote) continue; + const localVersion = normalizeThemeVersion(theme.version); + if (!localVersion) continue; + if (isOwnInstalledTheme(theme)) { + syncStates.set( + theme.folder_name, + await resolveThemeSyncState(theme, remote.theme, remote.version) + ); + } + } + custom_themes.update((themes) => themes.map((theme) => { const remote = remoteThemes.get(theme.folder_name); @@ -598,10 +730,13 @@ if (!localVersion) return theme; const updateAvailable = semver.gt(remote.version, localVersion); + const sync = syncStates.get(theme.folder_name); return { ...theme, + workshopVersion: remote.version, updateAvailable, availableVersion: updateAvailable ? remote.version : undefined, + ...(sync ?? {}), }; }) ); @@ -660,6 +795,7 @@ } } + themeContentHashCache.delete(theme.folder_name); communityThemeCache.delete(theme.folder_name); sileo.success({ title: 'Yaay!', @@ -677,6 +813,24 @@ }; $effect(() => { + const userId = $currentUser?.id; + const themeCount = $custom_themes.length; + const ownedCount = ownedWorkshopThemeIds.length + ownedWorkshopThemeNames.length; + + // Themes and the signed-in creator load asynchronously. Reset the check + // throttle when they change so the first badge is not missed by an early + // check that ran against an empty library or an unknown user. + if ( + userId !== lastSyncUserId || + themeCount !== lastSyncThemeCount || + ownedCount !== lastSyncOwnedCount + ) { + lastSyncUserId = userId; + lastSyncThemeCount = themeCount; + lastSyncOwnedCount = ownedCount; + lastThemeUpdateCheck = 0; + } + if (selectedView === 'THEMES') { untrack(() => void checkInstalledThemeUpdates()); } @@ -760,6 +914,7 @@ // copy when the saved theme was already the active one. const wasActive = $active_custom_theme?.folder_name === folderName; await reloadThemes(); + await refreshThemeSyncState(folderName); if (!wasActive) return; const savedTheme = get(custom_themes).find((theme) => theme.folder_name === folderName); @@ -848,8 +1003,10 @@ try { const themeFolder = await path.join(await themesDirectory(), theme.folder_name); + themeContentHashCache.delete(theme.folder_name); await restoreThemeBackup(themeFolder, backup.path); await reloadThemes(); + await refreshThemeSyncState(theme.folder_name); const restoredTheme = get(custom_themes).find((candidate) => candidate.name === theme.name); if (wasActive && restoredTheme) { @@ -885,6 +1042,7 @@ const refreshThemes = async () => { refreshingThemes = true; try { + themeContentHashCache.clear(); await reloadThemes(); void checkInstalledThemeUpdates(true); } catch (error) { @@ -1836,6 +1994,18 @@ } catch (error) { console.error(error); } + + // The publish succeeded, so the local version is now the workshop + // version. Clear the stale "Not synced" badge before the next check. + custom_themes.update((themes) => + themes.map((candidate) => { + const matchesId = themeId && candidate.workshopId?.trim().toLowerCase() === themeId; + const matchesName = themeName && candidate.name.trim().toLowerCase() === themeName; + return matchesId || matchesName + ? { ...candidate, workshopVersion: candidate.version, isNotSynced: false } + : candidate; + }) + ); }).then((unlisten) => (themePublishedUnlisten = unlisten)); return () => { @@ -4096,9 +4266,18 @@ theme.status === 'installed' && (theme.backups?.length ?? 0) > 0} {@const canUpdateTheme = theme.status === 'installed' && theme.updateAvailable} + {@const isOwnTheme = isOwnInstalledTheme(theme)} + {@const localVersion = normalizeThemeVersion(theme.version)} + {@const workshopVersion = theme.workshopVersion} + {@const versionMismatch = + localVersion !== undefined && + workshopVersion !== undefined && + workshopVersion !== localVersion} + {@const isNotSynced = + isOwnTheme && (theme.isNotSynced === true || versionMismatch)} {@const canReportTheme = Boolean($currentUser && ownedWorkshopLoaded) && - !isOwnInstalledTheme(theme) && + !isOwnTheme && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'} @@ -4178,6 +4357,17 @@ API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'} {/if} + {#if isNotSynced} + + Not synced + + {/if}
{#if theme.nsfw}