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; } export const LEGACY_COMMUNITY_THEMES: CommunityTheme[] = [ { id: 'execution_clap', name: 'Execution Clap', description: 'High-energy animated music video theme featuring Camellia\'s "Execution Clap" with background video playback and intense beats.', version: '1.0.0', apiVersion: '1.0', hasExecutableCode: true, fileSize: 59213509, checksumSha256: '', downloadCount: 142, likeCount: 28, tags: ['legacy', 'apiv1', 'video', 'anime'], createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', author: { id: 1001, name: 'HorizonCode', donor: true, avatarUrl: 'https://a.ez-pp.farm/1001', }, isLiked: false, previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/execution_clap.png', downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/execution_clap.ezpplauncher-theme', }, { id: 'dance', name: 'Dance', description: 'Vibrant and rhythmic animated theme featuring the viral Dance animation with synchronized soundtrack.', version: '1.0.0', apiVersion: '1.0', hasExecutableCode: true, fileSize: 21956554, checksumSha256: '', downloadCount: 98, likeCount: 19, tags: ['legacy', 'apiv1', 'video', 'anime'], createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', author: { id: 1001, name: 'HorizonCode', donor: true, avatarUrl: 'https://a.ez-pp.farm/1001', }, isLiked: false, previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/dance.png', downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/dance.ezpplauncher-theme', }, { id: 'lagtrain', name: 'Lagtrain', description: 'The iconic monochrome animated theme featuring inabakumori\'s "Lagtrain" (ラグトレイン) with Kaai Yuki vocals.', version: '1.1.0', apiVersion: '1.0', hasExecutableCode: true, fileSize: 45781440, checksumSha256: '', downloadCount: 230, likeCount: 54, tags: ['legacy', 'apiv1', 'video', 'anime'], createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', author: { id: 1001, name: 'HorizonCode', donor: true, avatarUrl: 'https://a.ez-pp.farm/1001', }, isLiked: false, previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/lagtrain.png', downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/lagtrain.ezpplauncher-theme', }, { id: 'looping_the_rooms', name: 'Looping the Rooms', description: 'Aesthetic, hypnotic looping room transitions with chill background audio.', version: '1.0.0', apiVersion: '1.0', hasExecutableCode: true, fileSize: 14507000, checksumSha256: '', downloadCount: 76, likeCount: 14, tags: ['legacy', 'apiv1', 'video'], createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', author: { id: 1001, name: 'HorizonCode', donor: true, avatarUrl: 'https://a.ez-pp.farm/1001', }, isLiked: false, previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/looping_the_rooms.png', downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/looping_the_rooms.ezpplauncher-theme', }, { id: 'daidaidaidaidaikirai', name: 'Daidaidaidaidaikirai', description: 'Animated music video theme featuring "Daidaidaidaidaikirai" (ダイダイダイダイダイキライ) with animated visuals and audio playback.', version: '1.1.0', apiVersion: '1.0', hasExecutableCode: true, fileSize: 19800000, checksumSha256: '', downloadCount: 185, likeCount: 41, tags: ['legacy', 'apiv1', 'video', 'anime'], createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', author: { id: 1001, name: 'HorizonCode', donor: true, avatarUrl: 'https://a.ez-pp.farm/1001', }, isLiked: false, previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/daidaidaidaidaikirai.png', downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/daidaidaidaidaikirai.ezpplauncher-theme', }, { id: 'teto_the_31st', name: 'Teto the 31st', description: 'Kasane Teto tribute theme celebrating Teto the 31st with vibrant animated video and vocaloid audio.', version: '1.1.0', apiVersion: '1.0', hasExecutableCode: true, fileSize: 23400000, checksumSha256: '', downloadCount: 310, likeCount: 82, tags: ['legacy', 'apiv1', 'video', 'anime'], createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', author: { id: 1001, name: 'HorizonCode', donor: true, avatarUrl: 'https://a.ez-pp.farm/1001', }, isLiked: false, previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/teto_the_31st.png', downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/teto_the_31st.ezpplauncher-theme', }, ]; 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; } /** * Returns authentication headers using stored username and password, * plus session token if available. */ export async function getAuthHeaders(): Promise> { const headers: Record = {}; const username = await config.value('username').get(''); const password = await config.value('password').get(''); if (username.length > 0 && password.length > 0) { headers['X-Username'] = username; headers['X-Password'] = password; } const token = await getThemesAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } return headers; } 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 { let remoteData: CommunityThemesResponse | null = null; let remoteError: Error | null = null; try { const baseUrl = await getThemesServiceUrl(); const headers = await getAuthHeaders(); 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 res = await fetch(url.toString(), { headers }); if (res.ok) { remoteData = (await res.json()) as CommunityThemesResponse; for (const item of remoteData.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}`; } } } else { remoteError = new Error(`Failed to load themes (HTTP ${res.status})`); } } catch (err) { remoteError = err instanceof Error ? err : new Error(String(err)); console.warn('[CommunityThemes] Microservice unavailable, falling back to legacy catalogue:', err); } // Filter legacy themes based on options const matchingLegacy = LEGACY_COMMUNITY_THEMES.filter((item) => { if (options?.authorId && options.authorId !== item.author.id) return false; if (options?.tag && options.tag !== 'all' && !item.tags.includes(options.tag.toLowerCase())) return false; if (options?.q && options.q.trim().length > 0) { const q = options.q.trim().toLowerCase(); const text = `${item.name} ${item.description} ${item.id}`.toLowerCase(); if (!text.includes(q)) return false; } return true; }); if (!remoteData) { if (matchingLegacy.length > 0 || !remoteError) { return { items: matchingLegacy, total: matchingLegacy.length, page: 1, totalPages: 1, }; } throw remoteError; } // If remoteData returned, merge legacy themes if not already present in the list const existingIds = new Set(remoteData.items.map((i) => i.id.toLowerCase())); const missingLegacy = matchingLegacy.filter((l) => !existingIds.has(l.id.toLowerCase())); const allItems = [...remoteData.items, ...missingLegacy]; return { items: allItems, total: remoteData.total + missingLegacy.length, page: remoteData.page, totalPages: Math.ceil((remoteData.total + missingLegacy.length) / (options?.limit || 24)) || 1, }; } export async function fetchCommunityThemeDetails(themeId: string): Promise { const cleanId = themeId.trim().toLowerCase(); try { const baseUrl = await getThemesServiceUrl(); const headers = await getAuthHeaders(); const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(cleanId)}`, { headers }); if (res.ok) { 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; } } catch {} const legacy = LEGACY_COMMUNITY_THEMES.find((t) => t.id === cleanId); if (legacy) return legacy; throw new Error('Theme not found.'); } export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> { const baseUrl = await getThemesServiceUrl(); const headers = await getAuthHeaders(); if (!headers['X-Username'] && !headers['Authorization']) { throw new Error('Please log in to like community themes.'); } const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/like`, { method: 'POST', headers, }); 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 headers = await getAuthHeaders(); if (!headers['X-Username'] && !headers['Authorization']) { throw new Error('Please log in to manage your community themes.'); } const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}`, { method: 'DELETE', headers, }); 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 authHeaders = await getAuthHeaders(); if (!authHeaders['X-Username'] && !authHeaders['Authorization']) { 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', ...authHeaders, }, 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', ...authHeaders, }, 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', ...authHeaders, }, 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); let res: Response; try { res = await fetch(theme.downloadUrl); if (!res.ok && (theme.apiVersion === '1.0' || theme.tags?.includes('legacy'))) { res = await fetch( `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${theme.id}.ezpplauncher-theme`, { headers: { 'User-Agent': 'EZPPLauncher' } } ); } } catch (err) { if (theme.apiVersion === '1.0' || theme.tags?.includes('legacy')) { res = await fetch( `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${theme.id}.ezpplauncher-theme`, { headers: { 'User-Agent': 'EZPPLauncher' } } ); } else { throw err; } } 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(() => {}); } } }