From 8816852fd96218b05951cbe32c28fbb826ceaa67 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Sun, 1 Nov 2026 15:43:24 +0100 Subject: [PATCH] feat(themes): implement community themes workshop and chunked uploader --- src-tauri/src/commands.rs | 47 ++ src-tauri/src/lib.rs | 6 +- src/lib/communityThemes.ts | 386 +++++++++++++++ .../theme/CommunityWorkshopView.svelte | 468 ++++++++++++++++++ .../components/theme/ThemeEditorWindow.svelte | 275 +++++++++- src/screens/Launch.svelte | 117 +++-- 6 files changed, 1255 insertions(+), 44 deletions(-) create mode 100644 src/lib/communityThemes.ts create mode 100644 src/lib/components/theme/CommunityWorkshopView.svelte diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ae44b68..a81282a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -86,6 +86,53 @@ pub fn file_size(path: String) -> Option { metadata.is_file().then_some(metadata.len()) } +/// Calculate the SHA-256 checksum of a file on disk. +#[tauri::command] +pub async fn hash_file_sha256(path: String) -> Result { + use sha2::{Digest, Sha256}; + use std::io::Read; + + tokio::task::spawn_blocking(move || { + let mut file = std::fs::File::open(&path) + .map_err(|e| format!("Failed to open file for hashing: {e}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let bytes_read = file + .read(&mut buffer) + .map_err(|e| format!("Failed to read file for hashing: {e}"))?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + Ok::<_, String>(hex::encode(hasher.finalize())) + }) + .await + .map_err(|e| format!("Thread error: {e}"))? +} + +/// 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> { + use std::io::{Read, Seek, SeekFrom}; + + tokio::task::spawn_blocking(move || { + let mut file = std::fs::File::open(&path) + .map_err(|e| format!("Failed to open file for chunk read: {e}"))?; + file.seek(SeekFrom::Start(offset)) + .map_err(|e| format!("Failed to seek to offset {offset}: {e}"))?; + let mut buffer = vec![0u8; length as usize]; + let bytes_read = file + .read(&mut buffer) + .map_err(|e| format!("Failed to read chunk bytes: {e}"))?; + buffer.truncate(bytes_read); + Ok::<_, String>(buffer) + }) + .await + .map_err(|e| format!("Thread error: {e}"))? +} + #[tauri::command] pub fn get_hwid() -> String { get_id() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e496e1..18573da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,12 +24,12 @@ use crate::{ 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, install_ezpp_launcher_update, installation_diagnostics, + has_wmctrl, hash_file_sha256, 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, presence_connect, presence_disconnect, presence_is_connected, presence_update_activity, - presence_update_button, presence_update_status, presence_update_user, read_theme_info, replace_ui_files, + presence_update_button, presence_update_status, presence_update_user, read_file_chunk, read_theme_info, replace_ui_files, resolve_osu_folder, restore_theme_backup, reveal_window, run_open_tablet_driver, run_osu, run_osu_updater, save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values, stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url, @@ -194,6 +194,8 @@ pub fn run() { download_and_extract_theme, save_theme_project, export_theme, + hash_file_sha256, + read_file_chunk, open_theme_folder, open_theme_editor, focus_theme_editor, diff --git a/src/lib/communityThemes.ts b/src/lib/communityThemes.ts new file mode 100644 index 0000000..d3d92b6 --- /dev/null +++ b/src/lib/communityThemes.ts @@ -0,0 +1,386 @@ +import { invoke } from '@tauri-apps/api/core'; +import * as path from '@tauri-apps/api/path'; +import * as fs from '@tauri-apps/plugin-fs'; +import { config } from './config'; +import { reloadThemes, themesDirectory } from './themes'; + +export interface CommunityThemeAuthor { + id: number; + name: string; + donor: boolean; + avatarUrl: string; +} + +export interface CommunityTheme { + id: string; + name: string; + description: string; + version: string; + apiVersion: string; + hasExecutableCode: boolean; + fileSize: number; + checksumSha256: string; + downloadCount: number; + likeCount: number; + tags: string[]; + createdAt: string; + updatedAt: string; + author: CommunityThemeAuthor; + isLiked: boolean; + previewUrl: string; + downloadUrl: string; +} + +export interface CommunityThemesResponse { + items: CommunityTheme[]; + total: number; + page: number; + totalPages: number; +} + +const DEFAULT_THEMES_SERVICE_URL = 'http://127.0.0.1:3000'; +let cachedSessionToken: string | null = null; + +export async function getThemesServiceUrl(): Promise { + const customUrl = await config.value('themes_service_url').get(''); + return customUrl.trim().length > 0 ? customUrl.trim().replace(/\/+$/, '') : DEFAULT_THEMES_SERVICE_URL; +} + +export async function getThemesAuthToken(forceRefresh = false): Promise { + if (!forceRefresh && cachedSessionToken) { + return cachedSessionToken; + } + + const storedToken = await config.value('themes_auth_token').get(''); + if (!forceRefresh && storedToken.trim().length > 0) { + cachedSessionToken = storedToken.trim(); + return cachedSessionToken; + } + + // Attempt automatic login using saved username & password + const username = await config.value('username').get(''); + const password = await config.value('password').get(''); + + if (username.length > 0 && password.length > 0) { + try { + const baseUrl = await getThemesServiceUrl(); + const res = await fetch(`${baseUrl}/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + if (res.ok) { + const data = (await res.json()) as { token: string }; + if (data.token) { + cachedSessionToken = data.token; + await config.value('themes_auth_token').set(data.token); + return cachedSessionToken; + } + } + } catch (err) { + console.warn('[CommunityThemes] Auto-login failed:', err); + } + } + + return null; +} + +export async function fetchCommunityThemes(options?: { + q?: string; + tag?: string; + authorId?: number; + sort?: 'popular' | 'recent' | 'likes' | 'downloads'; + page?: number; + limit?: number; +}): Promise { + const baseUrl = await getThemesServiceUrl(); + const token = await getThemesAuthToken(); + + const url = new URL(`${baseUrl}/v1/themes`); + if (options?.q) url.searchParams.set('q', options.q); + if (options?.tag) url.searchParams.set('tag', options.tag); + if (options?.authorId) url.searchParams.set('authorId', String(options.authorId)); + if (options?.sort) url.searchParams.set('sort', options.sort); + if (options?.page) url.searchParams.set('page', String(options.page)); + if (options?.limit) url.searchParams.set('limit', String(options.limit)); + + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(url.toString(), { headers }); + if (!res.ok) { + throw new Error(`Failed to load themes (HTTP ${res.status})`); + } + + const data = (await res.json()) as CommunityThemesResponse; + // Ensure absolute preview and download URLs + for (const item of data.items) { + if (item.previewUrl && !item.previewUrl.startsWith('http')) { + item.previewUrl = `${baseUrl}${item.previewUrl}`; + } + if (item.downloadUrl && !item.downloadUrl.startsWith('http')) { + item.downloadUrl = `${baseUrl}${item.downloadUrl}`; + } + } + return data; +} + +export async function fetchCommunityThemeDetails(themeId: string): Promise { + const baseUrl = await getThemesServiceUrl(); + const token = await getThemesAuthToken(); + + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}`, { headers }); + if (!res.ok) { + throw new Error(`Theme not found (HTTP ${res.status})`); + } + + const item = (await res.json()) as CommunityTheme; + if (item.previewUrl && !item.previewUrl.startsWith('http')) { + item.previewUrl = `${baseUrl}${item.previewUrl}`; + } + if (item.downloadUrl && !item.downloadUrl.startsWith('http')) { + item.downloadUrl = `${baseUrl}${item.downloadUrl}`; + } + return item; +} + +export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> { + const baseUrl = await getThemesServiceUrl(); + const token = await getThemesAuthToken(); + + if (!token) { + throw new Error('Please log in to like community themes.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/like`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to like theme (HTTP ${res.status})`); + } + + return (await res.json()) as { liked: boolean; likeCount: number }; +} + +export async function deleteCommunityTheme(themeId: string): Promise { + const baseUrl = await getThemesServiceUrl(); + const token = await getThemesAuthToken(); + + if (!token) { + throw new Error('Please log in to manage your community themes.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to delete theme (HTTP ${res.status})`); + } +} + +/** + * 3-step Cloudflare-compliant chunked upload protocol (~15 MB slices). + */ +export async function publishThemeToWorkshop(options: { + themeFolder: string; + themeId: string; + name: string; + description?: string; + version?: string; + apiVersion?: string; + tags?: string[]; + previewImageBase64?: string; + onProgress?: (status: string, percentage: number) => void; +}): Promise<{ success: boolean; theme: any }> { + const { onProgress = () => {} } = options; + const baseUrl = await getThemesServiceUrl(); + const token = await getThemesAuthToken(); + + if (!token) { + throw new Error('You must be logged in to EZPPFarm to publish themes.'); + } + + onProgress('Packaging theme archive...', 5); + + const baseThemesDir = await themesDirectory(); + const tmpDir = await path.join(baseThemesDir, '.tmp'); + if (!(await fs.exists(tmpDir))) { + await fs.mkdir(tmpDir, { recursive: true }); + } + + const destinationZip = await path.join(tmpDir, `${options.themeId}_workshop_export.zip`); + + try { + // 1. Export theme to a local zip archive + await invoke('export_theme', { + themeFolder: options.themeFolder, + destinationPath: destinationZip, + }); + + onProgress('Calculating checksum...', 10); + + const totalSize = (await invoke('file_size', { path: destinationZip })) ?? 0; + if (totalSize === 0) { + throw new Error('Failed to create theme archive (0 bytes).'); + } + + const sha256 = await invoke('hash_file_sha256', { path: destinationZip }); + + const CHUNK_SIZE = 15 * 1024 * 1024; // 15 MB + const totalChunks = Math.ceil(totalSize / CHUNK_SIZE); + + onProgress('Initializing upload session...', 15); + + // Step 1: Init upload session + const initRes = await fetch(`${baseUrl}/v1/themes/upload/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + themeId: options.themeId, + name: options.name, + description: options.description || '', + version: options.version || '1.0.0', + apiVersion: options.apiVersion || '2.0.0', + totalSize, + totalChunks, + sha256, + tags: options.tags || [], + }), + }); + + if (!initRes.ok) { + const errData = (await initRes.json().catch(() => ({}))) as { error?: string }; + throw new Error(errData.error || `Upload initialization failed (HTTP ${initRes.status})`); + } + + const { uploadId } = (await initRes.json()) as { uploadId: string }; + + // Step 2: Upload chunks sequentially + for (let i = 0; i < totalChunks; i++) { + const offset = i * CHUNK_SIZE; + const length = Math.min(CHUNK_SIZE, totalSize - offset); + + const progressStart = 20 + Math.round((i / totalChunks) * 65); + onProgress(`Uploading chunk ${i + 1} of ${totalChunks}...`, progressStart); + + const chunkBytes = await invoke('read_file_chunk', { + path: destinationZip, + offset, + length, + }); + + const chunkData = new Uint8Array(chunkBytes); + + const chunkRes = await fetch(`${baseUrl}/v1/themes/upload/${uploadId}/chunk/${i}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + Authorization: `Bearer ${token}`, + }, + body: chunkData, + }); + + if (!chunkRes.ok) { + const errData = (await chunkRes.json().catch(() => ({}))) as { error?: string }; + throw new Error(errData.error || `Failed uploading chunk ${i + 1}`); + } + } + + // Step 3: Complete upload + onProgress('Verifying integrity & finalizing...', 90); + + const completeRes = await fetch(`${baseUrl}/v1/themes/upload/${uploadId}/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + previewImageBase64: options.previewImageBase64, + }), + }); + + if (!completeRes.ok) { + const errData = (await completeRes.json().catch(() => ({}))) as { error?: string }; + throw new Error(errData.error || `Finalizing upload failed (HTTP ${completeRes.status})`); + } + + const completeData = await completeRes.json(); + onProgress('Theme published successfully!', 100); + + return { success: true, theme: completeData }; + } finally { + // Clean up temporary local zip + if (await fs.exists(destinationZip)) { + await fs.remove(destinationZip).catch(() => {}); + } + } +} + +/** + * Download, extract, and register a community theme into ~/.ezpplauncher/themes/. + */ +export async function downloadAndInstallCommunityTheme( + theme: CommunityTheme, + onProgress?: (status: string, percentage: number) => void +): Promise<{ success: boolean; folderName: string }> { + const { onProgress: reportProgress = () => {} } = { onProgress }; + const baseThemesDir = await themesDirectory(); + const folderName = theme.id; + const targetFolder = await path.join(baseThemesDir, folderName); + + reportProgress('Downloading theme package...', 10); + + const res = await fetch(theme.downloadUrl); + if (!res.ok) { + throw new Error(`Download failed (HTTP ${res.status})`); + } + + const tmpDir = await path.join(baseThemesDir, '.tmp'); + if (!(await fs.exists(tmpDir))) { + await fs.mkdir(tmpDir, { recursive: true }); + } + + const tempFile = await path.join(tmpDir, `${folderName}_download.ezpplauncher-theme`); + + try { + const arrayBuffer = await res.arrayBuffer(); + await fs.writeFile(tempFile, new Uint8Array(arrayBuffer)); + + reportProgress('Extracting theme files...', 70); + + if (!(await fs.exists(targetFolder))) { + await fs.mkdir(targetFolder, { recursive: true }); + } + + await invoke('extract_theme', { + filePath: tempFile, + themeFolder: targetFolder, + themeName: theme.name, + }); + + reportProgress('Theme installed!', 100); + + // Refresh themes in launcher state + await reloadThemes(); + + return { success: true, folderName }; + } finally { + if (await fs.exists(tempFile)) { + await fs.remove(tempFile).catch(() => {}); + } + } +} diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte new file mode 100644 index 0000000..9eadca7 --- /dev/null +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -0,0 +1,468 @@ + + +
+ +
+
+ +
+ {#each TAGS as tag} + + {/each} +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+ + {#if loading && themes.length === 0} +
+ +

Connecting to EZPPFarm Community Workshop...

+
+ {:else if error && themes.length === 0} +
+
+ +
+
+

Workshop Offline or Unreachable

+

+ {error}. Ensure the themes microservice container is running. +

+
+ +
+ {:else if themes.length === 0} +
+ +

No community themes found matching your search.

+
+ {:else} +
+ {#each themes as theme (theme.id)} + {@const localTheme = localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase())} + {@const isInstalled = Boolean(localTheme)} + {@const isActive = Boolean(localTheme && $active_custom_theme?.folder_name === localTheme.folder_name)} + {@const isDownloading = downloadingId === theme.id} + {@const hasUpdate = Boolean(localTheme && localTheme.version !== theme.version)} + +
+ +
+ {theme.name} { + // Fallback to default theme preview if server preview not loaded + (e.currentTarget as HTMLImageElement).src = DefaultThemePreview; + }} + /> +
+ + +
+ {#if !theme.hasExecutableCode} + + + Declarative + + {:else} + + + Svelte Code + + {/if} +
+ + +
+ + v{theme.version} + +
+ + +
+
+ {theme.author.name} { + (e.currentTarget as HTMLElement).style.display = 'none'; + }} + /> + + {theme.author.name} + + {#if theme.author.donor} + + + Supporter + + {/if} +
+ + {formatBytes(theme.fileSize)} + +
+
+ + +
+
+
+

+ {theme.name} +

+ + + +
+ + {#if theme.description} +

+ {theme.description} +

+ {/if} + + {#if theme.tags && theme.tags.length > 0} +
+ {#each theme.tags.slice(0, 3) as tag} + + #{tag} + + {/each} +
+ {/if} +
+ + +
+
+ + {theme.downloadCount} +
+ +
+ {#if isDownloading} +
+ {downloadProgress}% +
+
+
+
+ {:else if isActive} + + In use + + {:else if isInstalled && hasUpdate} + + {:else if isInstalled} + + {:else} + + {/if} +
+
+
+
+ {/each} +
+ {/if} +
+
+
diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index e243464..33a671c 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -100,6 +100,7 @@ themesDirectory, toSafeName, } from '$lib/themes'; + import { publishThemeToWorkshop } from '$lib/communityThemes'; import type { EZPPUser, EZPPUserInfo } from '$lib/types'; import { currentUser } from '$lib/userAuthentication'; import { osuInstallationPath } from '$lib/userSettings'; @@ -126,6 +127,7 @@ EyeOff, Film, Gauge, + Globe, GripVertical, Hand, Image as ImageIcon, @@ -3329,6 +3331,100 @@ } } + let publishModalOpen = $state(false); + let publishThemeId = $state(''); + let publishName = $state(''); + let publishDescription = $state(''); + let publishVersion = $state('1.0.0'); + let publishTags = $state('anime, video'); + let publishing = $state(false); + let publishStatusMessage = $state(''); + let publishProgress = $state(0); + + async function openPublishDialog() { + if (exporting || saving || publishing) return; + + if (!$currentUser) { + sileo.error({ + title: 'Authentication Required', + description: + 'You must be logged in to your EZPPFarm account to publish themes to the workshop.', + }); + return; + } + + const report = await validateTheme(); + validationReport = report; + const errors = report.issues.some((issue) => issue.level === 'error'); + if (errors) { + validationOpen = true; + exportAfterValidation = false; + return; + } + + if (dirty) { + await saveTheme({ notify: false }); + } + + publishThemeId = savedFolder || toSafeName(definition.name); + publishName = definition.name || 'My Theme'; + publishDescription = definition.description || ''; + publishVersion = definition.version || '1.0.0'; + publishTags = 'workshop, v2'; + publishProgress = 0; + publishStatusMessage = ''; + publishModalOpen = true; + } + + async function handlePublishToWorkshop() { + if (publishing) return; + if (!savedFolder) { + await saveTheme({ notify: false }); + if (!savedFolder) return; + } + + publishing = true; + publishProgress = 5; + publishStatusMessage = 'Packaging theme archive...'; + + try { + const baseThemesDir = await themesDirectory(); + const themeFolder = await path.join(baseThemesDir, savedFolder); + + await publishThemeToWorkshop({ + themeFolder, + themeId: publishThemeId, + name: publishName, + description: publishDescription, + version: publishVersion, + apiVersion: definition.apiVersion || '2.0.0', + tags: publishTags + .split(',') + .map((t) => t.trim()) + .filter(Boolean), + onProgress: (msg, pct) => { + publishStatusMessage = msg; + publishProgress = pct; + }, + }); + + sileo.success({ + title: 'Published to Workshop!', + description: `"${publishName}" is now live in the Community Themes Workshop.`, + }); + + publishModalOpen = false; + } catch (err) { + console.error(err); + sileo.error({ + title: 'Publish Failed', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + publishing = false; + } + } + async function closeEditor() { if (saving) return; if (dirty) { @@ -3751,7 +3847,7 @@ variant="secondary" size="sm" onclick={() => exportCurrentTheme()} - disabled={exporting || saving} + disabled={exporting || saving || publishing} > {#if exporting} @@ -3760,6 +3856,20 @@ {/if} Export + + {/if} + + + {#if $currentUser} +
+
+ {$currentUser.name} { + (e.currentTarget as HTMLElement).style.display = 'none'; + }} + /> +
+
+ {$currentUser.name} + {#if $currentUser.donor} + + Supporter + + {/if} +
+ Creator & Publisher +
+
+ Verified Account +
+ {/if} + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +