diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e59d4e9..8ab9a60 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -909,6 +909,23 @@ struct WorkshopInstallRequest { instance_id: String, zips: std::collections::HashMap, package_id: String, + version: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +struct InstalledWorkshopPackage { + id: String, + version: String, + dirs: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +struct InstalledPackageEntry { + instance_id: String, + package_id: String, + version: String, } #[tauri::command] @@ -919,19 +936,31 @@ async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Re 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 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 wp_path = instance_dir.join("workshop_packages.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 mut workshop_packages: Vec = fs::read_to_string(&wp_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + workshop_packages.retain(|p| p.id != request.package_id); + let raw_base = format!("https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/{}", request.package_id); - let tmp_dir = root.join(format!("workshop_tmp_{}", 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())?; + + let mut pkg_dirs: Vec = Vec::new(); + for (zip_name, placeholder) in &request.zips { let zip_url = format!("{}/{}", raw_base, zip_name); let zip_tmp = tmp_dir.join(zip_name); @@ -942,6 +971,7 @@ async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Re } 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 { @@ -954,6 +984,7 @@ async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Re }; fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; + #[cfg(target_os = "linux")] { let status = Command::new("unzip") @@ -979,11 +1010,71 @@ async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Re let dest_str = dest_dir.to_string_lossy().to_string(); if !workshop_files.contains(&dest_str) { - workshop_files.push(dest_str); + workshop_files.push(dest_str.clone()); + } + if !pkg_dirs.contains(&dest_str) { + pkg_dirs.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); + } + + workshop_packages.push(InstalledWorkshopPackage { + id: request.package_id.clone(), + version: request.version.clone(), + dirs: pkg_dirs, + }); + if let Ok(json) = serde_json::to_string(&workshop_packages) { + let _ = fs::write(&wp_path, json); + } + + Ok(()) +} + +#[tauri::command] +async fn workshop_uninstall(app: AppHandle, instance_id: String, package_id: String) -> Result<(), String> { + let root = get_app_dir(&app); + let instance_dir = root.join("instances").join(&instance_id); + let wp_path = instance_dir.join("workshop_packages.json"); + let wf_path = instance_dir.join("workshop_files.json"); + + let mut packages: Vec = fs::read_to_string(&wp_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + if let Some(pkg) = packages.iter().find(|p| p.id == package_id) { + for dir in &pkg.dirs { + let path = PathBuf::from(dir); + if path.is_dir() { + let _ = fs::remove_dir_all(&path); + } else if path.is_file() { + let _ = fs::remove_file(&path); + } + } + } + + let removed_dirs: std::collections::HashSet = packages + .iter() + .find(|p| p.id == package_id) + .map(|p| p.dirs.iter().cloned().collect()) + .unwrap_or_default(); + + packages.retain(|p| p.id != package_id); + + if let Ok(json) = serde_json::to_string(&packages) { + let _ = fs::write(&wp_path, 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(); + workshop_files.retain(|f| !removed_dirs.contains(f)); if let Ok(json) = serde_json::to_string(&workshop_files) { let _ = fs::write(&wf_path, json); } @@ -991,6 +1082,31 @@ async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Re Ok(()) } +#[tauri::command] +fn workshop_list_installed(app: AppHandle) -> Vec { + let root = get_app_dir(&app); + let instances_dir = root.join("instances"); + let mut result = Vec::new(); + let Ok(entries) = fs::read_dir(&instances_dir) else { return result; }; + for entry in entries.flatten() { + if !entry.path().is_dir() { continue; } + let instance_id = entry.file_name().to_string_lossy().to_string(); + let wp_path = entry.path().join("workshop_packages.json"); + let packages: Vec = fs::read_to_string(&wp_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + for pkg in packages { + result.push(InstalledPackageEntry { + instance_id: instance_id.clone(), + package_id: pkg.id, + version: pkg.version, + }); + } + } + result +} + #[tauri::command] #[allow(non_snake_case)] async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: String, servers: Vec) -> Result<(), String> { @@ -1343,7 +1459,7 @@ pub fn run() { } } }) - .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, sync_dlc, fetch_skin, workshop_install, get_screenshots, delete_screenshot, save_global_skin_pck]) + .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, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, save_global_skin_pck]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/components/views/WorkshopView.tsx b/src/components/views/WorkshopView.tsx index 1042b19..bcda391 100644 --- a/src/components/views/WorkshopView.tsx +++ b/src/components/views/WorkshopView.tsx @@ -3,14 +3,12 @@ import { motion, AnimatePresence } from 'framer-motion'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useUI, useAudio, useConfig, GameContext } from '../../context/LauncherContext'; -import { TauriService } from '../../services/TauriService'; - +import { TauriService, InstalledWorkshopPackage } from '../../services/TauriService'; const REGISTRY_URL = 'https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/registry.json'; const RAW_BASE = 'https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main'; const CATEGORY_TABS = ['Skin', 'Texture', 'World', 'Mod', 'DLC'] as const; -const ALL_TABS = [...CATEGORY_TABS, 'Search'] as const; +const ALL_TABS = [...CATEGORY_TABS, 'Installed', 'Search'] as const; type TabType = typeof ALL_TABS[number]; - interface RegistryPackage { id: string; name: string; @@ -24,7 +22,6 @@ interface RegistryPackage { } const COLS = 4; - const WorkshopView = memo(function WorkshopView() { const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); @@ -32,7 +29,6 @@ const WorkshopView = memo(function WorkshopView() { 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); @@ -40,10 +36,20 @@ const WorkshopView = memo(function WorkshopView() { const [focusedIdx, setFocusedIdx] = useState(null); const [search, setSearch] = useState(''); const [selectedPkg, setSelectedPkg] = useState(null); + const [installedPkgs, setInstalledPkgs] = useState([]); + const refreshInstalled = useCallback(async () => { + try { + const data = await TauriService.workshopListInstalled(); + setInstalledPkgs(data); + } catch { + setInstalledPkgs([]); + } + }, []); useEffect(() => { containerRef.current?.focus(); - }, []); + refreshInstalled(); + }, [refreshInstalled]); useEffect(() => { setLoading(true); @@ -63,17 +69,38 @@ const WorkshopView = memo(function WorkshopView() { }); }, []); - 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) - ); - }); + const getInstalledEntries = useCallback((pkgId: string) => { + return installedPkgs.filter((p) => p.packageId === pkgId); + }, [installedPkgs]); + + const isInstalled = useCallback((pkgId: string) => { + return installedPkgs.some((p) => p.packageId === pkgId); + }, [installedPkgs]); + + const hasUpdate = useCallback((pkg: RegistryPackage) => { + const entries = installedPkgs.filter((p) => p.packageId === pkg.id); + return entries.length > 0 && entries.some((e) => e.version !== pkg.version); + }, [installedPkgs]); + + const installedPackageList = allPackages.filter((pkg) => isInstalled(pkg.id)); + const filteredItems = activeTab === 'Installed' + ? (search.trim() + ? installedPackageList.filter((pkg) => { + const q = search.toLowerCase(); + return pkg.name.toLowerCase().includes(q) || pkg.author.toLowerCase().includes(q) || pkg.description.toLowerCase().includes(q); + }) + : installedPackageList) + : 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); @@ -162,7 +189,8 @@ const WorkshopView = memo(function WorkshopView() { }, [playBackSound, playPressSound, setActiveView, cycleTab, filteredItems, focusedIdx, selectedPkg, openModal]); const isSearchTab = activeTab === 'Search'; - + const isInstalledTab = activeTab === 'Installed'; + const showSearch = isSearchTab || isInstalledTab; return ( {ALL_TABS.map((tab) => { const isActive = tab === activeTab; + const updateCount = tab === 'Installed' + ? allPackages.filter((p) => hasUpdate(p)).length + : 0; return ( ); })} @@ -204,14 +240,12 @@ const WorkshopView = memo(function WorkshopView() {
- {isSearchTab ? ( + {showSearch ? ( { setSearch(e.target.value); setFocusedIdx(null); }} - placeholder="ENTER KEYWORDS..." + placeholder={isInstalledTab ? "FILTER INSTALLED..." : "ENTER KEYWORDS..."} spellCheck={false} - autoFocus + autoFocus={isSearchTab} className="bg-transparent border-none outline-none text-white text-lg mc-text-shadow w-full placeholder-white/40 font-['Mojangles'] tracking-widest" /> {search && ( @@ -248,13 +282,19 @@ const WorkshopView = memo(function WorkshopView() {
- {!search.trim() ? ( + {isSearchTab && !search.trim() ? (
Start typing to search...
+ ) : loading ? ( +
+ Searching Archives... +
) : filteredItems.length === 0 ? (
- No results + + {isInstalledTab ? 'Nothing Installed' : 'No results'} +
) : (
@@ -266,6 +306,8 @@ const WorkshopView = memo(function WorkshopView() { focused={focusedIdx === i} onHover={() => setFocusedIdx(i)} onClick={() => openModal(pkg)} + installed={isInstalled(pkg.id)} + hasUpdate={hasUpdate(pkg)} /> ))}
@@ -305,6 +347,8 @@ const WorkshopView = memo(function WorkshopView() { focused={focusedIdx === i} onHover={() => setFocusedIdx(i)} onClick={() => openModal(pkg)} + installed={isInstalled(pkg.id)} + hasUpdate={hasUpdate(pkg)} /> ))}
@@ -328,23 +372,31 @@ const WorkshopView = memo(function WorkshopView() { {selectedPkg && ( - + )}
); }); -function PackageCard({ pkg, index, focused, onHover, onClick }: { +function PackageCard({ pkg, index, focused, onHover, onClick, installed, hasUpdate }: { pkg: RegistryPackage; index: number; focused: boolean; onHover: () => void; onClick: () => void; + installed: boolean; + hasUpdate: boolean; }) { const thumbnailUrl = `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; const [imgError, setImgError] = useState(false); - return (
{c} ))}
+ {hasUpdate && ( +
+ Update +
+ )} + {installed && !hasUpdate && ( +
+ Installed +
+ )}
@@ -387,34 +449,48 @@ function PackageCard({ pkg, index, focused, onHover, onClick }: { ); } -function PackageModal({ pkg, onClose, playPressSound }: { +function PackageModal({ pkg, onClose, playPressSound, installedEntries, onInstallComplete, onUninstallComplete }: { pkg: RegistryPackage; onClose: () => void; playPressSound: () => void; + installedEntries: InstalledWorkshopPackage[]; + onInstallComplete: () => void; + onUninstallComplete: () => void; }) { const thumbnailUrl = `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; const [imgError, setImgError] = useState(false); - const [modalFocus, setModalFocus] = useState<'install' | 'close'>('install'); + const [modalFocus, setModalFocus] = useState<'install' | 'uninstall' | 'close'>('install'); const [showInstall, setShowInstall] = useState(false); + const [showUninstall, setShowUninstall] = useState(false); + const hasInstalled = installedEntries.length > 0; + const needsUpdate = hasInstalled && installedEntries.some((e) => e.version !== pkg.version); + const focusOptions: Array<'install' | 'uninstall' | 'close'> = hasInstalled + ? ['install', 'uninstall', 'close'] + : ['install', 'close']; useEffect(() => { - if (showInstall) return; //neo: let install modal handle keys + if (showInstall || showUninstall) return; 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'); + setModalFocus((p) => { + const idx = focusOptions.indexOf(p); + return focusOptions[(idx + 1) % focusOptions.length]; + }); } else if (e.key === 'Enter') { if (modalFocus === 'close') onClose(); else if (modalFocus === 'install') setShowInstall(true); + else if (modalFocus === 'uninstall') setShowUninstall(true); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [modalFocus, showInstall, onClose, playPressSound]); + }, [modalFocus, showInstall, showUninstall, onClose, playPressSound, focusOptions]); + const installLabel = !hasInstalled ? 'INSTALL' : needsUpdate ? 'UPDATE' : 'REINSTALL'; return ( <> {pkg.name} By {pkg.author}
+ {needsUpdate && ( +
+ Update Available +
+ )} + {hasInstalled && !needsUpdate && ( +
+ Installed +
+ )}
@@ -482,6 +568,15 @@ function PackageModal({ pkg, onClose, playPressSound }: { Package ID: {pkg.id}
+ {hasInstalled && ( +
+ Installed: + + v{installedEntries[0]?.version} + {needsUpdate ? ' (outdated)' : ''} + +
+ )}
@@ -519,8 +614,22 @@ function PackageModal({ pkg, onClose, playPressSound }: { imageRendering: 'pixelated', }} > - INSTALL + {installLabel} + {hasInstalled && ( + + )} +
+ )} + + {status === 'idle' && installedEntries.map((entry, i) => ( +
uninstallFrom(entry.instanceId)} + onMouseEnter={() => setFocusedIdx(i)} + className={`flex items-center justify-between p-3 cursor-pointer border-2 transition-none ${focusedIdx === i ? 'border-[#FF5555] bg-black/40' : 'border-[#444] bg-black/20'}`} + > + {editionName(entry.instanceId)} + v{entry.version} +
+ ))} + + + + ); +} + export default WorkshopView; diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index 02a1b57..e719906 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -57,6 +57,12 @@ export interface MacOSSetupProgress { percent?: number; } +export interface InstalledWorkshopPackage { + instanceId: string; + packageId: string; + version: string; +} + export class TauriService { static async saveConfig(config: AppConfig): Promise { return invoke("save_config", { config }); @@ -128,12 +134,21 @@ export class TauriService { instanceId: string, packageId: string, zips: Record, + version: string, ): Promise { return invoke("workshop_install", { - request: { instanceId, packageId, zips }, + request: { instanceId, packageId, zips, version }, }); } + static async workshopUninstall(instanceId: string, packageId: string): Promise { + return invoke("workshop_uninstall", { instanceId, packageId }); + } + + static async workshopListInstalled(): Promise { + return invoke("workshop_list_installed"); + } + static onDownloadProgress(callback: (percent: number) => void) { return listen("download-progress", (event) => callback(event.payload),