feat(themes): flag unpublished edits and share parallax motion

Compare theme content hashes with a stored Workshop baseline so creators
can see when their local copy is not synced. Extract pointer tracking into
a shared action for the launcher and editor preview, and use the slider
component for parallax depth.
This commit is contained in:
HorizonCode committed 2026-11-04 12:04:13 +01:00
1 parent d56ed9d786
commit 323a6f18af
12 files changed
+667 -134

No files matched your search

+20
View File
@@ -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": [
+133
View File
@@ -112,6 +112,136 @@ pub async fn hash_file_sha256(path: String) -> Result<String, String> {
.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<String, String> {
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<String>>,
files: &mut Vec<PathBuf>,
) -> 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<Vec<u8>, 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}"))?;
+4 -2
View File
@@ -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,
+2
View File
@@ -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);
}
@@ -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 @@
<div
style="width:{CANVAS_WIDTH}px;height:{CANVAS_HEIGHT}px;transform:scale({canvasScale});transform-origin:top left;"
>
<ThemeStage
{definition}
assets={assetBase}
assetOverrides={previewAssets}
{selectedIds}
{selectedGroupId}
interactive
scale={canvasScale}
variables={previewScriptVariables}
onselect={(id, additive) => 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}
/>
<div
class="contents"
use:themeParallax={{
enabled: definition.parallax?.enabled === true,
intensity: definition.parallax?.intensity ?? 0.25,
}}
>
<ThemeStage
{definition}
assets={assetBase}
assetOverrides={previewAssets}
{selectedIds}
{selectedGroupId}
interactive
scale={canvasScale}
variables={previewScriptVariables}
onselect={(id, additive) => 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}
/>
</div>
</div>
</ContextMenu.Trigger>
@@ -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 @@
<span class="text-[9px] text-muted-foreground">3D cursor perspective</span>
</div>
<div class="flex items-center gap-2">
<input
type="range"
min="-1"
max="1"
step="0.05"
<Slider
min={-1}
max={1}
step={0.05}
value={transform.parallax ?? 0}
oninput={(e) => 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"
/>
<span class="w-8 font-mono text-[10px] text-right text-muted-foreground">
{Math.round((transform.parallax ?? 0) * 100)}%
@@ -1,6 +1,7 @@
<script lang="ts">
import { custom_theme_volume } from '$lib/global';
import { playThemeMedia, themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
import { themeParallax } from '$lib/themeParallax';
import type { ThemeV2Definition } from '$lib/themeV2';
import { themeAssetUrl } from '$lib/themeV2';
import { reduceAnimations } from '$lib/userSettings';
@@ -20,50 +21,8 @@
} = $props();
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
let mousePx = $state(0);
let mousePy = $state(0);
let targetPx = 0;
let targetPy = 0;
let animFrame: number | undefined;
function onWindowMouseMove(e: MouseEvent) {
if ($reduceAnimations || !definition.parallax?.enabled) return;
const intensity = definition.parallax.intensity ?? 0.25;
const w = window.innerWidth || 1000;
const h = window.innerHeight || 650;
targetPx = (e.clientX / w - 0.5) * 2 * intensity;
targetPy = (e.clientY / h - 0.5) * 2 * intensity;
if (animFrame === undefined) {
animFrame = requestAnimationFrame(updateParallax);
}
}
function updateParallax() {
animFrame = undefined;
mousePx += (targetPx - mousePx) * 0.12;
mousePy += (targetPy - mousePy) * 0.12;
if (Math.abs(targetPx - mousePx) > 0.001 || Math.abs(targetPy - mousePy) > 0.001) {
animFrame = requestAnimationFrame(updateParallax);
}
}
$effect(() => {
if (!definition.parallax?.enabled) {
mousePx = 0;
mousePy = 0;
targetPx = 0;
targetPy = 0;
if (animFrame !== undefined) {
cancelAnimationFrame(animFrame);
animFrame = undefined;
}
}
});
onDestroy(() => {
if (animFrame !== undefined) {
cancelAnimationFrame(animFrame);
}
if (audioElement) {
try {
audioElement.pause();
@@ -140,64 +99,68 @@
});
</script>
<svelte:window onmousemove={onWindowMouseMove} />
<div
class="theme-v2 absolute inset-0 overflow-hidden"
use:trackMedia
style="--mouse-px:{mousePx};--mouse-py:{mousePy};"
>
{#if background.type === 'color'}
<div
class="absolute inset-0"
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'gradient'}
<div
class="absolute inset-0"
style="background-image:linear-gradient({background.angle ??
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'image'}
{#if background.src}
<img
class="absolute inset-0 h-full w-full"
src={assetUrl(background.src)}
alt=""
aria-hidden="true"
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur
? 'scale(1.08)'
: definition.parallax?.enabled
? 'scale(1.06)'
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
/>
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
<div
class="absolute inset-0"
use:themeParallax={{
enabled: definition.parallax?.enabled === true,
intensity: definition.parallax?.intensity ?? 0.25,
}}
style="transform:translate3d(calc(var(--mouse-px, 0) * -18px), calc(var(--mouse-py, 0) * -18px), 0);"
>
{#if background.type === 'color'}
<div
class="absolute inset-0"
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'gradient'}
<div
class="absolute inset-0"
style="background-image:linear-gradient({background.angle ??
135}deg, {background.from}, {background.to});opacity:{background.opacity ??
1};transform:{definition.parallax?.enabled ? 'scale(1.06)' : 'none'}"
></div>
{:else if background.type === 'image'}
{#if background.src}
<img
class="absolute inset-0 h-full w-full"
src={assetUrl(background.src)}
alt=""
aria-hidden="true"
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur
? 'scale(1.08)'
: definition.parallax?.enabled
? 'scale(1.06)'
: 'none'};"
/>
{/if}
{:else if background.type === 'video'}
{#if background.src}
<video
class="absolute inset-0 h-full w-full object-cover"
use:themeMediaSource={assetUrl(background.src)}
use:themeVideoLifecycle
autoplay
loop
muted={background.muted !== false}
use:applyMuted={background.muted !== false}
playsinline
disablepictureinpicture
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur
? 'scale(1.08)'
: definition.parallax?.enabled
? 'scale(1.06)'
: 'none'};"
></video>
{/if}
{/if}
{:else if background.type === 'video'}
{#if background.src}
<video
class="absolute inset-0 h-full w-full object-cover"
use:themeMediaSource={assetUrl(background.src)}
use:themeVideoLifecycle
autoplay
loop
muted={background.muted !== false}
use:applyMuted={background.muted !== false}
playsinline
disablepictureinpicture
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur
? 'scale(1.08)'
: definition.parallax?.enabled
? 'scale(1.06)'
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
></video>
{/if}
{/if}
</div>
{#if overlay && (overlay.opacity ?? 0) > 0}
<div
@@ -141,6 +141,15 @@
);
const elementFilter = (element: ThemeV2Element) =>
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}
+101
View File
@@ -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');
},
};
}
+94
View File
@@ -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<ThemeWorkshopSync | undefined> => {
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<ThemeWorkshopSync>;
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<void> => {
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<string>('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<ThemeWorkshopSync> => {
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<ThemeV2Definition>)
: 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,
+8 -1
View File
@@ -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 @@
</AlertDialog.Content>
</AlertDialog.Root>
<main use:themeMediaSuspension>
<main
use:themeMediaSuspension
use:themeParallax={{
enabled: $active_custom_theme?.definition?.parallax?.enabled === true,
intensity: $active_custom_theme?.definition?.parallax?.intensity ?? 0.25,
}}
>
<div class:hidden={!!$active_custom_theme?.definition}>
<div class="opacity-30">
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
+191 -1
View File
@@ -122,8 +122,10 @@
checkThemeFromFile,
deleteTheme,
downloadTheme,
hashThemeContent,
importThemeFromFile,
loadTheme,
markThemeSynced,
openThemesFolder,
reloadThemes,
restoreThemeBackup,
@@ -317,6 +319,7 @@
let ownedWorkshopThemeIds = $state<string[]>([]);
let ownedWorkshopThemeNames = $state<string[]>([]);
let ownedWorkshopLoaded = $state(false);
const themeContentHashCache = new SvelteMap<string, string>();
let ownedThemesRequestId = 0;
let logViewerOpen = $state(false);
let presenceEditorOpen = $state(false);
@@ -326,6 +329,9 @@
const communityThemeCache = new SvelteMap<string, CommunityTheme>();
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<ThemeSyncState> => {
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<string, ThemeSyncState>();
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'}
</Badge>
{/if}
{#if isNotSynced}
<Badge
variant="outline"
class="border-amber-500/40 bg-amber-950/70 px-1.5 py-0 text-[9px] text-amber-200 backdrop-blur-sm"
title={versionMismatch
? `Local v${theme.version} · Workshop v${workshopVersion}`
: 'Local content differs from the published Workshop version'}
>
Not synced
</Badge>
{/if}
</div>
<div class="absolute top-1.5 right-1.5 flex items-center gap-1">
{#if theme.nsfw}