From 6328f60a6192999ec2f407b22ec30100904c61b2 Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Mon, 6 Apr 2026 13:27:49 +0300 Subject: [PATCH] feat: initial support for Workshop --- src-tauri/src/lib.rs | 106 +++- src/components/views/WorkshopView.tsx | 715 ++++++++++++++++++++++++-- src/services/TauriService.ts | 4 + 3 files changed, 775 insertions(+), 50 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d109708..41c4113 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -651,13 +651,25 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u if !instance_dir.exists() { fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; } else { + let workshop_files: std::collections::HashSet = { + let wf_path = instance_dir.join("workshop_files.json"); + fs::read_to_string(&wf_path) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() + .into_iter() + .collect() + }; + if let Ok(entries) = fs::read_dir(&instance_dir) { for entry in entries.flatten() { let file_name = entry.file_name(); let name_str = file_name.to_string_lossy(); - let keep_list = ["Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat", "servers.dat", "servers.txt", "server.properties", "Common", "options.txt", "servers.db"]; - if !keep_list.contains(&name_str.as_ref()) { + let keep_list = ["Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat", "servers.dat", "servers.txt", "server.properties", "Common", "options.txt", "servers.db", "workshop_files.json"]; + let entry_path_str = entry.path().to_string_lossy().to_string(); + let is_workshop_file = workshop_files.iter().any(|wf| entry_path_str.starts_with(wf) || wf.starts_with(&entry_path_str)); + if !keep_list.contains(&name_str.as_ref()) && !is_workshop_file { let path = entry.path(); if path.is_dir() { let _ = fs::remove_dir_all(path); @@ -921,6 +933,94 @@ async fn sync_dlc(app: AppHandle, instance_id: String) -> Result<(), String> { perform_dlc_sync(&app, &instance_dir) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkshopInstallRequest { + instance_id: String, + zips: std::collections::HashMap, + package_id: String, +} + +#[tauri::command] +async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Result<(), String> { + let root = get_app_dir(&app); + let instance_dir = root.join("instances").join(&request.instance_id); + if !instance_dir.exists() { + return Err("Instance not installed".into()); + } + + let media_dir = instance_dir.join("Windows64Media"); + let dlc_dir = media_dir.join("DLC"); + let game_hdd = instance_dir.join("Windows64").join("GameHDD"); + let mob_dir = instance_dir.join("Common").join("res").join("mob"); + let wf_path = instance_dir.join("workshop_files.json"); + let mut workshop_files: Vec = fs::read_to_string(&wf_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let raw_base = format!("https://raw.githubusercontent.com/Emerald-Legacy-Launcher/Workshop/refs/heads/main/{}", request.package_id); + let tmp_dir = root.join(format!("workshop_tmp_{}", request.package_id)); + fs::create_dir_all(&tmp_dir).map_err(|e| e.to_string())?; + for (zip_name, placeholder) in &request.zips { + let zip_url = format!("{}/{}", raw_base, zip_name); + let zip_tmp = tmp_dir.join(zip_name); + let response = reqwest::get(&zip_url).await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + let _ = fs::remove_dir_all(&tmp_dir); + return Err(format!("Failed to download {}: HTTP {}", zip_name, response.status())); + } + let bytes = response.bytes().await.map_err(|e| e.to_string())?; + fs::write(&zip_tmp, &bytes).map_err(|e| e.to_string())?; + let dest_dir = if placeholder.is_empty() { + instance_dir.clone() + } else { + let resolved = placeholder + .replace("{MediaDir}", media_dir.to_str().unwrap_or("")) + .replace("{DLCDir}", dlc_dir.to_str().unwrap_or("")) + .replace("{GameHDD}", game_hdd.to_str().unwrap_or("")) + .replace("{MobDir}", mob_dir.to_str().unwrap_or("")); + PathBuf::from(resolved) + }; + + fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; + #[cfg(target_os = "linux")] + { + let status = Command::new("unzip") + .args(["-o", "-q", zip_tmp.to_str().unwrap(), "-d", dest_dir.to_str().unwrap()]) + .status() + .map_err(|e| e.to_string())?; + if !status.success() { + let _ = fs::remove_dir_all(&tmp_dir); + return Err(format!("Extraction failed for {}", zip_name)); + } + } + #[cfg(not(target_os = "linux"))] + { + let status = Command::new("tar") + .args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()]) + .status() + .map_err(|e| e.to_string())?; + if !status.success() { + let _ = fs::remove_dir_all(&tmp_dir); + return Err(format!("Extraction failed for {}", zip_name)); + } + } + + let dest_str = dest_dir.to_string_lossy().to_string(); + if !workshop_files.contains(&dest_str) { + workshop_files.push(dest_str); + } + } + + let _ = fs::remove_dir_all(&tmp_dir); + if let Ok(json) = serde_json::to_string(&workshop_files) { + let _ = fs::write(&wf_path, json); + } + + Ok(()) +} + #[tauri::command] #[allow(non_snake_case)] async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: String, servers: Vec) -> Result<(), String> { @@ -1205,7 +1305,7 @@ pub fn run() { .plugin(tauri_plugin_gamepad::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_drpc::init()) - .invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, update_tray_icon, sync_dlc, fetch_skin]) + .invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, update_tray_icon, sync_dlc, fetch_skin, workshop_install]) .setup(|app| { let config = load_config(app.handle().clone()); let visible = config.enable_tray_icon.unwrap_or(true); diff --git a/src/components/views/WorkshopView.tsx b/src/components/views/WorkshopView.tsx index 713cdf1..6eb19f4 100644 --- a/src/components/views/WorkshopView.tsx +++ b/src/components/views/WorkshopView.tsx @@ -1,47 +1,668 @@ -import { useState, useEffect, memo } from 'react'; -import { motion } from 'framer-motion'; -import { useUI, useAudio, useConfig } from '../../context/LauncherContext'; - -const WorkshopView = memo(function WorkshopView() { - const { setActiveView } = useUI(); - const { playPressSound } = useAudio(); - const [backHover, setBackHover] = useState(false); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' || e.key === 'Backspace') { - playPressSound(); - setActiveView('main'); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [playPressSound, setActiveView]); - - return ( - -

Workshop

- -
- Workshop support coming soon... -
- - -
- ); -}); - -export default WorkshopView; \ No newline at end of file +import { useState, useEffect, memo, useCallback, useRef, useContext } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useUI, useAudio, useConfig, GameContext } from '../../context/LauncherContext'; +import { TauriService } from '../../services/TauriService'; + +const REGISTRY_URL = 'https://raw.githubusercontent.com/Emerald-Legacy-Launcher/Workshop/refs/heads/main/registry.json'; +const RAW_BASE = 'https://raw.githubusercontent.com/Emerald-Legacy-Launcher/Workshop/refs/heads/main'; + +const CATEGORY_TABS = ['Skin', 'Texture', 'World', 'Mod', 'DLC'] as const; +const ALL_TABS = [...CATEGORY_TABS, 'Search'] as const; +type TabType = typeof ALL_TABS[number]; + +interface RegistryPackage { + id: string; + name: string; + author: string; + description: string; + category: string[]; + thumbnail: string; + zips: Record; + version: string; +} + +const COLS = 4; + +const WorkshopView = memo(function WorkshopView() { + const { setActiveView } = useUI(); + const { playPressSound, playBackSound } = useAudio(); + const config = useConfig(); + const containerRef = useRef(null); + const gridRef = useRef(null); + const searchRef = useRef(null); + + const [activeTab, setActiveTab] = useState('Skin'); + const [allPackages, setAllPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [focusedIdx, setFocusedIdx] = useState(null); + const [search, setSearch] = useState(''); + const [selectedPkg, setSelectedPkg] = useState(null); + + useEffect(() => { + containerRef.current?.focus(); + }, []); + + useEffect(() => { + setLoading(true); + setError(null); + fetch(REGISTRY_URL) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data) => { + setAllPackages(data.packages ?? []); + setLoading(false); + }) + .catch((e) => { + setError(e.message ?? 'Failed to load registry'); + setLoading(false); + }); + }, []); + + const filteredItems = allPackages.filter((pkg) => { + const matchesTab = activeTab === 'Search' ? true : pkg.category.includes(activeTab); + if (!matchesTab) return false; + if (!search.trim()) return activeTab === 'Search' ? false : true; + const q = search.toLowerCase(); + return ( + pkg.name.toLowerCase().includes(q) || + pkg.author.toLowerCase().includes(q) || + pkg.description.toLowerCase().includes(q) + ); + }); + + useEffect(() => { + setFocusedIdx(null); + if (activeTab === 'Search') { + setTimeout(() => searchRef.current?.focus(), 50); + } else { + setSearch(''); + } + }, [activeTab]); + + useEffect(() => { + if (focusedIdx !== null && gridRef.current) { + const el = gridRef.current.querySelector(`[data-card="${focusedIdx}"]`) as HTMLElement; + el?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIdx]); + + const cycleTab = useCallback((direction: 'next' | 'prev') => { + playPressSound(); + setActiveTab((prev) => { + const idx = ALL_TABS.indexOf(prev); + if (direction === 'next') return ALL_TABS[(idx + 1) % ALL_TABS.length]; + return ALL_TABS[(idx - 1 + ALL_TABS.length) % ALL_TABS.length]; + }); + }, [playPressSound]); + + const selectTab = useCallback((tab: TabType) => { + if (tab !== activeTab) { + playPressSound(); + setActiveTab(tab); + } + }, [activeTab, playPressSound]); + + const openModal = useCallback((pkg: RegistryPackage) => { + playPressSound(); + setSelectedPkg(pkg); + }, [playPressSound]); + + const closeModal = useCallback(() => { + playBackSound(); + setSelectedPkg(null); + }, [playBackSound]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (selectedPkg) return; + + const isSearchInput = document.activeElement === searchRef.current; + if (isSearchInput) { + if (e.key === 'Escape') { setSearch(''); containerRef.current?.focus(); } + return; + } + + const count = filteredItems.length; + if (e.key === 'Escape' || e.key === 'Backspace') { + playBackSound(); setActiveView('main'); return; + } + if (e.key === 'e' || e.key === 'E') { cycleTab('next'); return; } + if (e.key === 'q' || e.key === 'Q') { cycleTab('prev'); return; } + + if (count === 0) return; + + if (e.key === 'ArrowRight') { + e.preventDefault(); + setFocusedIdx((p) => Math.min((p ?? -1) + 1, count - 1)); + playPressSound(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + setFocusedIdx((p) => Math.max((p ?? 1) - 1, 0)); + playPressSound(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setFocusedIdx((p) => Math.min((p ?? -COLS) + COLS, count - 1)); + playPressSound(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setFocusedIdx((p) => Math.max((p ?? COLS) - COLS, 0)); + playPressSound(); + } else if (e.key === 'Enter' && focusedIdx !== null) { + const pkg = filteredItems[focusedIdx]; + if (pkg) openModal(pkg); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [playBackSound, playPressSound, setActiveView, cycleTab, filteredItems, focusedIdx, selectedPkg, openModal]); + + const isSearchTab = activeTab === 'Search'; + + return ( + +
+ +
+
+
+ {ALL_TABS.map((tab) => { + const isActive = tab === activeTab; + return ( + + ); + })} +
+ {!isSearchTab && ( + + Entries: {loading ? '...' : filteredItems.length} + + )} +
+
+ +
+ + {isSearchTab ? ( + +
+
+ { setSearch(e.target.value); setFocusedIdx(null); }} + placeholder="Search all workshop entries..." + spellCheck={false} + autoFocus + className="bg-transparent border-none outline-none text-white text-base mc-text-shadow w-full placeholder-[#555] font-['Mojangles']" + /> + {search && ( + + )} +
+ + {search.trim() ? `${filteredItems.length} result${filteredItems.length !== 1 ? 's' : ''}` : 'Type to search'} + +
+ + {search.trim() && ( +
+ {filteredItems.length === 0 ? ( +
+ No results +
+ ) : ( +
+ {filteredItems.map((pkg, i) => ( + setFocusedIdx(i)} + onClick={() => openModal(pkg)} + /> + ))} +
+ )} +
+ )} +
+ ) : loading ? ( + + Please wait + + ) : error ? ( + + {error} + + ) : filteredItems.length === 0 ? ( + + No entries + + ) : ( + +
+ {filteredItems.map((pkg, i) => ( + setFocusedIdx(i)} + onClick={() => openModal(pkg)} + /> + ))} +
+
+ )} +
+
+ +
+ +
+ + + {selectedPkg && ( + + )} + + + ); +}); + +function PackageCard({ pkg, index, focused, onHover, onClick }: { + pkg: RegistryPackage; + index: number; + focused: boolean; + onHover: () => void; + onClick: () => void; +}) { + const thumbnailUrl = `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; + const [imgError, setImgError] = useState(false); + + return ( +
+
+ {imgError ? ( + No Image + ) : ( + {pkg.name} setImgError(true)} /> + )} +
+
+ + {pkg.name} + + by {pkg.author} + {pkg.description} +
+ v{pkg.version} +
+ {pkg.category.map((c) => ( + {c} + ))} +
+
+
+
+ ); +} + +function PackageModal({ pkg, onClose, playPressSound }: { + pkg: RegistryPackage; + onClose: () => void; + playPressSound: () => void; +}) { + const thumbnailUrl = `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; + const [imgError, setImgError] = useState(false); + const [modalFocus, setModalFocus] = useState<'install' | 'close'>('install'); + const [showInstall, setShowInstall] = useState(false); + + useEffect(() => { + if (showInstall) return; // let install modal handle keys + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' || e.key === 'Backspace') { + onClose(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Tab') { + e.preventDefault(); + playPressSound(); + setModalFocus((p) => p === 'install' ? 'close' : 'install'); + } else if (e.key === 'Enter') { + if (modalFocus === 'close') onClose(); + else if (modalFocus === 'install') setShowInstall(true); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [modalFocus, showInstall, onClose, playPressSound]); + + return ( + <> + + e.stopPropagation()} + className="flex flex-col w-[560px] max-h-[80vh] overflow-hidden font-['Mojangles']" + style={{ + backgroundImage: "url('/images/frame_background.png')", + backgroundSize: '100% 100%', + imageRendering: 'pixelated', + }} + > +
+ {imgError ? ( +
+ No Image +
+ ) : ( + {pkg.name} setImgError(true)} /> + )} +
+
+ {pkg.name} + by {pkg.author} +
+
+ +
+

{pkg.description}

+ +
+
+ Version + v{pkg.version} +
+
+ Categories +
+ {pkg.category.map((c) => ( + {c} + ))} +
+
+
+ + {Object.keys(pkg.zips).length > 0 && ( +
+ Files + {Object.entries(pkg.zips).map(([file, dest]) => ( +
+ {file} + {dest && {dest}} +
+ ))} +
+ )} + +
+ + +
+
+ + + + + {showInstall && ( + setShowInstall(false)} playPressSound={playPressSound} /> + )} + + + ); +} + +function InstallModal({ pkg, onClose, playPressSound }: { + pkg: RegistryPackage; + onClose: () => void; + playPressSound: () => void; +}) { + const game = useContext(GameContext); + const availableEditions = game?.editions.filter(e => game.installs.includes(e.id)) || []; + + const [focusedIdx, setFocusedIdx] = useState(0); + const [status, setStatus] = useState<'idle' | 'installing' | 'success' | 'error'>('idle'); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + if (status === 'installing') return; + if (status === 'success') { + if (e.key === 'Escape' || e.key === 'Backspace' || e.key === 'Enter') onClose(); + return; + } + + if (e.key === 'Escape' || e.key === 'Backspace') { + onClose(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + playPressSound(); + setFocusedIdx((p) => Math.max(p - 1, 0)); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + playPressSound(); + setFocusedIdx((p) => Math.min(p + 1, availableEditions.length - 1)); + } else if (e.key === 'Enter') { + if (availableEditions.length > 0) { + installTo(availableEditions[focusedIdx].id); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [availableEditions, focusedIdx, status, onClose, playPressSound]); + + const installTo = async (instanceId: string) => { + setStatus('installing'); + setErrorMsg(null); + playPressSound(); + try { + await TauriService.workshopInstall(instanceId, pkg.id, pkg.zips); + setStatus('success'); + } catch (e: any) { + console.error(e); + setStatus('error'); + setErrorMsg(typeof e === 'string' ? e : e.message); + } + }; + + return ( + + e.stopPropagation()} + className="flex flex-col w-[480px] font-['Mojangles'] text-white" + style={{ + backgroundImage: "url('/images/frame_background.png')", + backgroundSize: '100% 100%', + imageRendering: 'pixelated', + }} + > +
+ Install to Edition + Select an installed edition for "{pkg.name}" +
+ +
+ {status === 'installing' && ( +
+ Installing... + Downloading and extracting assets +
+ )} + {status === 'success' && ( +
+ Installed Successfully! + Press any key or click to continue +
+ )} + {status === 'error' && ( +
+ Installation Failed + {errorMsg} + +
+ )} + + {status === 'idle' && ( + availableEditions.length === 0 ? ( +
+ No installed editions found +
+ ) : ( + availableEditions.map((ed, i) => ( +
installTo(ed.id)} + onMouseEnter={() => setFocusedIdx(i)} + className={`flex flex-col p-3 cursor-pointer border-2 transition-none ${focusedIdx === i ? 'border-[#FFFF55] bg-black/40' : 'border-[#444] bg-black/20'}`} + > + {ed.name} +
+ )) + ) + )} +
+
+
+ ); +} + +export default WorkshopView; diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index d337376..e922465 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -126,6 +126,10 @@ export class TauriService { return invoke("sync_dlc", { instanceId }); } + static async workshopInstall(instanceId: string, packageId: string, zips: Record): Promise { + return invoke("workshop_install", { request: { instanceId, packageId, zips } }); + } + static async updateTrayIcon(visible: boolean): Promise { return invoke("update_tray_icon", { visible }); }