diff --git a/src-tauri/src/commands/dlc.rs b/src-tauri/src/commands/dlc.rs new file mode 100644 index 0000000..7caec1c --- /dev/null +++ b/src-tauri/src/commands/dlc.rs @@ -0,0 +1,205 @@ +use std::fs; +use serde::Serialize; +use tauri::AppHandle; +use crate::util; +#[derive(Serialize)] +pub struct GitEntry { + pub name: String, + pub is_dir: bool, +} + +fn parse_git_url(url: &str) -> Result<(String, String, String, bool), String> { + let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?; + let host = parsed.host_str().ok_or("No host in URL")?.to_string(); + let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect(); + if segments.len() < 2 { + return Err("Invalid repo URL: need owner and repo".into()); + } + let owner = segments[0].to_string(); + let repo = segments[1].trim_end_matches(".git").to_string(); + let is_github = host == "github.com" || host == "raw.githubusercontent.com"; + Ok((owner, repo, host, is_github)) +} + +fn get_api_url(host: &str, owner: &str, repo: &str, path: &str, branch: &str, is_github: bool) -> String { + let api_path = if path.is_empty() || path == "." || path == "/" { + String::new() + } else { + format!("/{}", path.trim_start_matches('/')) + }; + if is_github { + format!("https://api.github.com/repos/{}/{}/contents{}?ref={}", owner, repo, api_path, branch) + } else { + format!("https://{}/api/v1/repos/{}/{}/contents{}?ref={}", host, owner, repo, api_path, branch) + } +} + +fn get_raw_url(host: &str, owner: &str, repo: &str, branch: &str, path: &str, is_github: bool) -> String { + if is_github { + format!("https://raw.githubusercontent.com/{}/{}/{}/{}", owner, repo, branch, path) + } else { + format!("https://{}/{}/{}/raw/branch/{}/{}", host, owner, repo, branch, path) + } +} + +#[tauri::command] +pub async fn list_git_directory( + repo_url: String, + branch: String, + path: String, +) -> Result, String> { + list_git_directory_inner(repo_url, branch, path).await +} + +fn list_git_directory_inner( + repo_url: String, + branch: String, + path: String, +) -> std::pin::Pin, String>> + Send>> { + Box::pin(async move { + let (owner, repo, host, is_github) = parse_git_url(&repo_url)?; + let api_url = get_api_url(&host, &owner, &repo, &path, &branch, is_github); + let client = reqwest::Client::new(); + let response = client.get(&api_url) + .header("User-Agent", "Emerald-Launcher") + .send() + .await + .map_err(|e| format!("Failed to fetch directory listing: {}", e))?; + + if !response.status().is_success() { + if response.status().as_u16() == 404 { + return Ok(Vec::new()); + } + return Err(format!("Git API returned {}", response.status())); + } + + let text = response.text().await.map_err(|e| e.to_string())?; + let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("Failed to parse response: {}", e))?; + let entries = match &json { + serde_json::Value::Array(arr) => arr.clone(), + serde_json::Value::Object(_) => { + let is_dir = json.get("type").and_then(|v| v.as_str()) == Some("dir"); + if is_dir { + let sub_path = json.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); + return list_git_directory_inner(repo_url, branch, sub_path).await; + } + return Ok(Vec::new()); + } + _ => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + for entry in &entries { + if let (Some(name), Some(type_val)) = ( + entry.get("name").and_then(|v| v.as_str()), + entry.get("type").and_then(|v| v.as_str()), + ) { + result.push(GitEntry { + name: name.to_string(), + is_dir: type_val == "dir", + }); + } + } + + Ok(result) + }) +} + +async fn collect_files( + host: &str, + owner: &str, + repo: &str, + branch: &str, + root_path: &str, + is_github: bool, +) -> Result, String> { + let mut files = Vec::new(); + let mut dirs_to_list = vec![root_path.to_string()]; + let client = reqwest::Client::new(); + while let Some(dir) = dirs_to_list.pop() { + let api_url = get_api_url(host, owner, repo, &dir, branch, is_github); + let response = client.get(&api_url) + .header("User-Agent", "Emerald-Launcher") + .send() + .await + .map_err(|e| format!("Failed to list {}: {}", dir, e))?; + + if !response.status().is_success() { + return Err(format!("Failed to list {}: HTTP {}", dir, response.status())); + } + + let text = response.text().await.map_err(|e| e.to_string())?; + let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("Failed to parse: {}", e))?; + let entries = match &json { + serde_json::Value::Array(arr) => arr.clone(), + serde_json::Value::Object(_) => { + let entry_type = json.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if entry_type == "dir" { + dirs_to_list.push(dir.clone()); + continue; + } + files.push(dir.clone()); + continue; + } + _ => continue, + }; + + for entry in &entries { + if let (Some(name), Some(type_val)) = ( + entry.get("name").and_then(|v| v.as_str()), + entry.get("type").and_then(|v| v.as_str()), + ) { + let full_path = if dir.is_empty() { name.to_string() } else { format!("{}/{}", dir, name) }; + if type_val == "dir" { + dirs_to_list.push(full_path); + } else { + files.push(full_path); + } + } + } + } + + Ok(files) +} + +#[tauri::command] +pub async fn download_dlc_files( + app: AppHandle, + instance_id: String, + repo_url: String, + branch: String, + dlc_folder: String, +) -> Result<(), String> { + let instance_dir = util::get_instance_working_dir(&app, &instance_id); + let dlc_dest = instance_dir.join("Windows64Media").join("DLC").join(&dlc_folder); + let (owner, repo, host, is_github) = parse_git_url(&repo_url)?; + let files_to_download = collect_files(&host, &owner, &repo, &branch, &dlc_folder, is_github).await?; + if files_to_download.is_empty() { + return Err(format!("No files found in '{}' folder", dlc_folder)); + } + + fs::create_dir_all(&dlc_dest).map_err(|e| e.to_string())?; + let client = reqwest::Client::new(); + for file_path in &files_to_download { + let raw_url = get_raw_url(&host, &owner, &repo, &branch, file_path, is_github); + let response = client.get(&raw_url) + .header("User-Agent", "Emerald-Launcher") + .send() + .await + .map_err(|e| format!("Failed to download {}: {}", file_path, e))?; + + if !response.status().is_success() { + return Err(format!("Failed to download {}: HTTP {}", file_path, response.status())); + } + + let bytes = response.bytes().await.map_err(|e| e.to_string())?; + let relative_path = file_path.strip_prefix(&format!("{}/", dlc_folder)).unwrap_or(file_path); + let dest_path = dlc_dest.join(relative_path); + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&dest_path, &bytes).map_err(|e| format!("Failed to write {}: {}", file_path, e))?; + } + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 11229a2..38b63f4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod console2lce; +pub mod dlc; pub mod download; pub mod file_dialogs; pub mod game; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc3ba01..c14f827 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use tokio::sync::Mutex; use tauri::{Emitter, Manager}; use commands::config as config_cmds; +use commands::dlc; use commands::download; use commands::file_dialogs; use commands::game; @@ -62,6 +63,8 @@ pub fn run() { .plugin(tauri_plugin_drpc::init()) .invoke_handler(tauri::generate_handler![ macos_setup::setup_macos_runtime, + dlc::list_git_directory, + dlc::download_dlc_files, game::launch_game, game::stop_game, game::check_game_installed, diff --git a/src/components/modals/DownloadDlcModal.tsx b/src/components/modals/DownloadDlcModal.tsx new file mode 100644 index 0000000..4a24203 --- /dev/null +++ b/src/components/modals/DownloadDlcModal.tsx @@ -0,0 +1,330 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { motion } from "framer-motion"; +import { TauriService } from "../../services/TauriService"; +interface DlcEntry { + name: string; + selected: boolean; +} + +export default function DownloadDlcModal({ + isOpen, + onClose, + playPressSound, + playBackSound, + editionName, + instanceId, + officialDLC, +}: { + isOpen: boolean; + onClose: () => void; + playPressSound: (s?: string) => void; + playBackSound: (s?: string) => void; + editionName: string; + instanceId: string; + officialDLC: string; +}) { + const [dlcList, setDlcList] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [downloading, setDownloading] = useState(false); + const [downloaded, setDownloaded] = useState([]); + const [focusIndex, setFocusIndex] = useState(0); + const listRef = useRef(null); + const [branch, repoUrl] = officialDLC.includes(":") + ? [ + officialDLC.slice(0, officialDLC.indexOf(":")), + officialDLC.slice(officialDLC.indexOf(":") + 1), + ] + : ["main", officialDLC]; + + const fetchDlcs = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [gitEntries, instancePath] = await Promise.all([ + TauriService.listGitDirectory(repoUrl, branch, ""), + TauriService.getInstancePath(instanceId), + ]); + + let existingDlcs: string[] = []; + try { + const dlcDirEntries = await TauriService.listDirectory( + instancePath + "/Windows64Media/DLC", + ); + existingDlcs = dlcDirEntries.filter((e) => e.is_dir).map((e) => e.name); + } catch { + existingDlcs = []; + } + + const folders = gitEntries + .filter((e) => e.is_dir) + .map((e) => e.name) + .sort(); + + setDlcList( + folders + .filter((name) => !existingDlcs.includes(name)) + .map((name) => ({ + name, + selected: false, + })), + ); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [repoUrl, branch, instanceId]); + + useEffect(() => { + if (isOpen) { + setDlcList([]); + setError(null); + setDownloading(false); + setDownloaded([]); + setFocusIndex(0); + fetchDlcs(); + } + }, [isOpen, fetchDlcs]); + + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (!downloading) { + playBackSound(); + onClose(); + } + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusIndex((prev) => Math.min(prev + 1, dlcList.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && !downloading) { + e.preventDefault(); + if (focusIndex >= 0 && focusIndex < dlcList.length) { + toggleDlc(dlcList[focusIndex].name); + } + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, dlcList, focusIndex, downloading, playBackSound, onClose]); + + useEffect(() => { + if (focusIndex >= 0 && listRef.current) { + const el = listRef.current.querySelector( + `[data-dlc-index="${focusIndex}"]`, + ) as HTMLElement; + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + }, [focusIndex]); + + const toggleDlc = (name: string) => { + playPressSound(); + setDlcList((prev) => + prev.map((d) => (d.name === name ? { ...d, selected: !d.selected } : d)), + ); + }; + + const selectAll = () => { + playPressSound(); + setDlcList((prev) => prev.map((d) => ({ ...d, selected: true }))); + }; + + const deselectAll = () => { + playPressSound(); + setDlcList((prev) => prev.map((d) => ({ ...d, selected: false }))); + }; + + const handleDownload = async () => { + const selected = dlcList.filter((d) => d.selected); + if (selected.length === 0) return; + playPressSound(); + setDownloading(true); + setDownloaded([]); + for (const dlc of selected) { + try { + await TauriService.downloadDlcFiles( + instanceId, + repoUrl, + branch, + dlc.name, + ); + setDownloaded((prev) => [...prev, dlc.name]); + } catch (e) { + setError( + `Failed to download ${dlc.name}: ${e instanceof Error ? e.message : String(e)}`, + ); + setDownloading(false); + return; + } + } + setDownloading(false); + }; + + const selectedCount = dlcList.filter((d) => d.selected).length; + if (!isOpen) return null; + return ( + +
+

+ Download DLC +

+

+ {editionName} +

+ + {loading && ( +
+ Loading available DLCs... +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && dlcList.length === 0 && ( +
+ No DLC folders found in the repository. +
+ )} + + {!loading && dlcList.length > 0 && ( + <> +
+ + {dlcList.length} available + +
+ + +
+
+ +
+ {dlcList.map((dlc, i) => ( +
!downloading && toggleDlc(dlc.name)} + className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors hover:bg-white/5 ${ + focusIndex === i ? "ring-1 ring-white bg-white/5" : "" + }`} + > +
+ {dlc.selected && ( + + ✔ + + )} +
+ + {dlc.name} + +
+ ))} +
+ + )} + +
+ {downloading && ( +
+ Downloading... ({downloaded.length}/{selectedCount}) +
+ )} +
+ +
+ {!downloading && selectedCount > 0 && ( + + )} + {downloading && downloaded.length === selectedCount && ( + + )} + +
+
+
+ ); +} diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index ee23ccb..de812bc 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -6,6 +6,7 @@ import SetUidModal from "../modals/SetUidModal"; import ImportWorldModal from "../modals/ImportWorldModal"; import PlaytimeModal from "../modals/PlaytimeModal"; import CustomizeModal from "../modals/CustomizeModal"; +import DownloadDlcModal from "../modals/DownloadDlcModal"; import { useUI, useConfig, @@ -103,6 +104,8 @@ const VersionsView = memo(function VersionsView() { } | null>(null); const [openMenuId, setOpenMenuId] = useState(null); const [deleteConfirmEdition, setDeleteConfirmEdition] = useState(null); + const [isDlcModalOpen, setIsDlcModalOpen] = useState(false); + const [dlcTargetEdition, setDlcTargetEdition] = useState(null); const containerRef = useRef(null); const listRef = useRef(null); const ITEM_COUNT = editions.length + 3; @@ -536,6 +539,33 @@ const VersionsView = memo(function VersionsView() { Download to custom path )} + {edition.officialDLC && isInstalled ? ( + + ) : null} {Array.isArray(edition.branches) && edition.branches.length > 0 && (