From 5bce5a3916083c4ad4f8b815ddcd7d84542f2919 Mon Sep 17 00:00:00 2001 From: martesi <96113508+martesi@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:09:44 +0800 Subject: [PATCH 1/7] refactor: propagate mod filesystem errors --- src-tauri/src/core/mod_fs.rs | 58 ++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/core/mod_fs.rs b/src-tauri/src/core/mod_fs.rs index a59d030..cde3887 100644 --- a/src-tauri/src/core/mod_fs.rs +++ b/src-tauri/src/core/mod_fs.rs @@ -6,7 +6,6 @@ use camino::{Utf8Path, Utf8PathBuf}; use serde::{Deserialize, Serialize}; use walkdir::WalkDir; -// Internal cache representation: includes files but NOT sent to frontend #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ModFS { @@ -17,24 +16,20 @@ pub struct ModFS { } pub fn resolve_id(spt_paths: &SPTPathRules, files: &[Utf8PathBuf]) -> Result { - // 1. Single-pass collection using BTreeSet for automatic sorting let ids: std::collections::BTreeSet = files .iter() .filter_map(|path| { - // Server check if let Ok(rel) = path.strip_prefix(&spt_paths.server_mods) { return rel.components().next().map(|c| c.as_str().to_string()); } - // Client check (DLLs only) if path.extension() == Some("dll") && let Ok(rel) = path.strip_prefix(&spt_paths.client_plugins) { - // Normalize path separators to forward slashes for consistent hashing return Some(rel.as_str().replace('\\', "/").to_string()); } - None // Ignore file if it matches neither + None }) .collect(); @@ -42,7 +37,6 @@ pub fn resolve_id(spt_paths: &SPTPathRules, files: &[Utf8PathBuf]) -> Result>().join("").to_lowercase(); Ok(hash_id(&concatenated)) } @@ -59,39 +53,37 @@ pub fn infer_mod_type(files: &[Utf8PathBuf], config: &SPTPathRules) -> ModType { } } -fn collect_files(base: &Utf8Path) -> (Vec, Vec) { - WalkDir::new(base) - .into_iter() - // 1. Convert Result to Option - .filter_map(Result::ok) - // 2. Filter for files only - .filter(|e| e.path().is_file()) - // 3. Transform to Utf8PathBuf and strip prefix using Result/Option combinators - .filter_map(|entry| { - Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) - .ok() - .and_then(|path| path.strip_prefix(base).ok().map(|p| p.to_path_buf())) - }) - // 4. Fold into a tuple of (AllFiles, Executables) - .fold((Vec::new(), Vec::new()), |(mut all, mut exes), path| { - // Use Option::filter to handle the conditional push without an "if" - path.extension() - .filter(|&ext| ext == "exe") - .inspect(|_| exes.push(path.clone())); - - all.push(path); - (all, exes) - }) +fn collect_files(base: &Utf8Path) -> Result<(Vec, Vec), SError> { + let mut files = Vec::new(); + let mut executables = Vec::new(); + + for entry in WalkDir::new(base) { + let entry = entry.map_err(|e| SError::IOError(e.to_string()))?; + if !entry.file_type().is_file() { + continue; + } + + let path = Utf8Path::from_path(entry.path()) + .ok_or_else(|| SError::ParseError(format!("Invalid UTF-8 path: {:?}", entry.path())))?; + let path = path.strip_prefix(base)?.to_path_buf(); + + if path.extension() == Some("exe") { + executables.push(path.clone()); + } + + files.push(path); + } + + Ok((files, executables)) } -/// Scans a mod root on disk and resolves it into a ModFS. pub fn scan(root: &Utf8Path, spt_paths: &SPTPathRules) -> Result { - let (files, executables) = collect_files(root); // Call once + let (files, executables) = collect_files(root)?; Ok(ModFS { id: resolve_id(spt_paths, &files)?, mod_type: infer_mod_type(&files, spt_paths), - files, // Use the same vector + files, executables, }) } From b9edf7198e8bc925f403efd4598d09e106033823 Mon Sep 17 00:00:00 2001 From: martesi <96113508+martesi@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:09:57 +0800 Subject: [PATCH 2/7] refactor: simplify recursive copy --- src-tauri/src/utils/file.rs | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/utils/file.rs b/src-tauri/src/utils/file.rs index f3d8a0e..c8d198b 100644 --- a/src-tauri/src/utils/file.rs +++ b/src-tauri/src/utils/file.rs @@ -34,8 +34,7 @@ pub fn read_dir(path: &Utf8Path) -> Result { std::fs::read_dir(path).map_err(Into::into) } -/// Writes contents to a temp file in the target's directory, then renames it -/// over the target - the target is never observable half-written. +/// Writes via a temp file so the target is never observable half-written. pub fn atomic_write(path: &Utf8Path, contents: impl AsRef<[u8]>) -> Result<(), SError> { let parent = path .parent() @@ -58,36 +57,24 @@ pub fn is_dir_empty(path: &Utf8Path) -> bool { .unwrap_or(false) } -/// Recursively copies a directory tree from source to destination. -/// Creates all necessary directories and overwrites existing files. pub fn copy_recursive(src: &Utf8Path, dst: &Utf8Path) -> Result<(), SError> { - // 1. Ensure the root destination directory exists std::fs::create_dir_all(dst)?; - for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { - // 2. Convert standard Path to Camino Utf8Path + for entry in WalkDir::new(src) { + let entry = entry.map_err(|e| SError::IOError(e.to_string()))?; let src_path = Utf8Path::from_path(entry.path()) .ok_or_else(|| SError::ParseError(format!("Invalid UTF-8 path: {:?}", entry.path())))?; - - // 3. Calculate the relative path from the source root - let rel_path = src_path.strip_prefix(src)?; - - // 4. Construct the final destination path - let dst_path = dst.join(rel_path); + let dst_path = dst.join(src_path.strip_prefix(src)?); if entry.file_type().is_dir() { - // 5. If it's a directory, create it in the destination std::fs::create_dir_all(&dst_path)?; - } else { - // 6. If it's a file, ensure the parent directory exists (safety check) - if let Some(parent) = dst_path.parent() - && !parent.exists() - { - std::fs::create_dir_all(parent)?; - } - // 7. Copy the file (Note: This overwrites existing files at the destination) - std::fs::copy(src_path, &dst_path)?; + continue; + } + + if let Some(parent) = dst_path.parent() { + std::fs::create_dir_all(parent)?; } + std::fs::copy(src_path, &dst_path)?; } Ok(()) From 97a09f6cd69bfbd5a7890bd6ae033fe9e3ce887f Mon Sep 17 00:00:00 2001 From: martesi <96113508+martesi@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:10:20 +0800 Subject: [PATCH 3/7] refactor: clarify mod staging control flow --- src-tauri/src/core/mod_stager.rs | 414 ++++++++++++++----------------- 1 file changed, 192 insertions(+), 222 deletions(-) diff --git a/src-tauri/src/core/mod_stager.rs b/src-tauri/src/core/mod_stager.rs index cf6101c..0bb9221 100644 --- a/src-tauri/src/core/mod_stager.rs +++ b/src-tauri/src/core/mod_stager.rs @@ -1,222 +1,192 @@ -use crate::core::mod_fs::{self, ModFS}; -use crate::models::error::SError; -use crate::models::paths::SPTPathRules; -use crate::utils::{archive, file, process}; -use camino::{Utf8Path, Utf8PathBuf}; -use sysinfo::System; -use tracing::debug; -use uuid::Uuid; - -#[derive(Debug)] -pub struct StagedMod { - pub fs: ModFS, - pub source_path: Utf8PathBuf, // The location in staging (or original folder) - pub is_staging: bool, // True if this is a temp folder we need to delete later - pub name: String, // The resolved name for the mod -} - -#[derive(Debug)] -pub struct StageMaterial { - pub rules: SPTPathRules, - pub root: Utf8PathBuf, - pub name: String, // Translated "Unknown mod" string from frontend for loose files -} - -/// Takes raw user inputs and converts them into validated ModFS objects ready for installation. -/// Uses a functional pipeline to resolve inputs. -pub fn resolve( - inputs: &[Utf8PathBuf], - StageMaterial { root, rules, name }: &StageMaterial, -) -> Result, SError> { - // 1. Guard Clause: Collective "Loose File" Check - // If the inputs collectively form a mod root, treat them as one unit immediately. - if is_game_root_structure(inputs, rules) { - return stage_loose_files(inputs, rules, root, name).map(|staged| vec![staged]); - } - - // 2. Functional Pipeline: Process individual inputs. - // Inputs that match no strategy (Option::None) are dropped; collecting - // into Result> returns the first Error if any occur. - inputs - .iter() - .filter_map(|input| { - // Chain strategies: Try Directory -> If None, Try Archive - process_as_directory(input, rules, name) - .or_else(|| process_as_archive(input, rules, root, name)) - }) - .collect() -} - -/// Checks if it is safe to install these mods. -pub fn any_mod_tool_running(sys: &mut System, mods_to_install: &[StagedMod]) -> Result<(), SError> { - let specific_paths: Vec<_> = mods_to_install - .iter() - .flat_map(|m| m.fs.executables.iter().map(|exe| m.source_path.join(exe))) - .collect(); - - if process::is_running(sys, &specific_paths) { - return Err(SError::ProcessRunning); - } - - Ok(()) -} - -// --- Strategy Functions (Option>) --- - -/// Strategy A: Input is a directory. -/// Returns: -/// - Some(Ok): Valid mod found. -/// - Some(Err): Valid mod structure found but failed to parse (Critical Error). -/// - None: Not a directory, or not a mod (safe to try next strategy). -fn process_as_directory( - input: &Utf8PathBuf, - rules: &SPTPathRules, - unknown_mod_name: &str, -) -> Option> { - if !input.is_dir() { - return None; - } - - // Sub-strategy A1: Folder has strict Game Root structure (user/ or BepInEx/) - // We use boolean matching to avoid deep nesting. - let is_game_structure = folder_matches_game_structure(input, rules); // Propagate IO errors if they happen - - match is_game_structure { - Ok(true) => { - // It IS a game structure, so it MUST be a valid mod. Fail if mod_fs::scan fails. - Some(mod_fs::scan(input, rules).map(|fs| { - // Determine name: directory name - let name = input.file_name().unwrap_or(unknown_mod_name).to_string(); - StagedMod { - fs, - source_path: input.clone(), - is_staging: false, - name, - } - })) - } - Ok(false) => { - // Sub-strategy A2: Folder is a standard mod folder. - // We try mod_fs::scan. If it succeeds, Good. If it fails, we treat it as "Not a mod" (None). - mod_fs::scan(input, rules).ok().map(|fs| { - // Determine name: directory name - let name = input.file_name().unwrap_or(unknown_mod_name).to_string(); - Ok(StagedMod { - fs, - source_path: input.clone(), - is_staging: false, - name, - }) - }) - } - Err(e) => Some(Err(e)), // Critical IO error reading dir - } -} - -/// Strategy B: Input is an archive. -fn process_as_archive( - input: &Utf8PathBuf, - rules: &SPTPathRules, - staging_root: &Utf8Path, - unknown_mod_name: &str, -) -> Option> { - archive::ArchiveFormat::from_path(input) - .map(|_| stage_archive(input, rules, staging_root, unknown_mod_name)) -} - -// --- Internal Helpers --- - -fn is_game_root_structure(inputs: &[Utf8PathBuf], rules: &SPTPathRules) -> bool { - let roots = [ - get_root_component(&rules.server_mods), - get_root_component(&rules.client_plugins), - ]; - - inputs.iter().any(|path| { - path.file_name() - .map(|name| roots.contains(&Some(name))) - .unwrap_or(false) - }) -} - -fn folder_matches_game_structure(folder: &Utf8Path, rules: &SPTPathRules) -> Result { - let roots = [ - get_root_component(&rules.server_mods), - get_root_component(&rules.client_plugins), - ]; - - // Using iterator to avoid manual loop - let has_match = file::read_dir(folder)? - .filter_map(|e| e.ok()) - .filter_map(|e| e.file_name().into_string().ok()) - .any(|name| roots.contains(&Some(name.as_str()))); - - Ok(has_match) -} - -fn stage_loose_files( - inputs: &[Utf8PathBuf], - rules: &SPTPathRules, - staging_root: &Utf8Path, - unknown_mod_name: &str, -) -> Result { - let uuid = Uuid::new_v4().to_string(); - let dest_dir = staging_root.join(uuid); - file::create_dir_all(&dest_dir)?; - - for input in inputs { - let name = input - .file_name() - .ok_or_else(|| SError::ParseError(format!("Unable to get file name for {input}")))?; - file::copy_recursive(input, &dest_dir.join(name))?; - } - - let fs = mod_fs::scan(&dest_dir, rules)?; - - // Determine name: translated "Unknown mod" for loose files - let name = unknown_mod_name.to_string(); - - Ok(StagedMod { - fs, - source_path: dest_dir, - is_staging: true, - name, - }) -} - -fn stage_archive( - archive: &Utf8Path, - rules: &SPTPathRules, - staging_root: &Utf8Path, - unknown_mod_name: &str, -) -> Result { - let uuid = Uuid::new_v4().to_string(); - let dest_dir = staging_root.join(uuid); - file::create_dir_all(&dest_dir)?; - - archive::extract(archive, &dest_dir)?; - - let fs = mod_fs::scan(&dest_dir, rules)?; - - // Determine name: archive name without extension - let name = archive.file_stem().unwrap_or(unknown_mod_name).to_string(); - - Ok(StagedMod { - fs, - source_path: dest_dir, - is_staging: true, - name, - }) -} - -fn get_root_component(path: &Utf8Path) -> Option<&str> { - path.components().next().map(|c| c.as_str()) -} - -pub fn clean_up(is_staging: bool, source_path: &Utf8Path) -> Result<(), SError> { - if !is_staging { - return Ok(()); - } - debug!("clean up for {source_path}"); - file::remove_dir_all(source_path) -} +use crate::core::mod_fs::{self, ModFS}; +use crate::models::error::SError; +use crate::models::paths::SPTPathRules; +use crate::utils::{archive, file, process}; +use camino::{Utf8Path, Utf8PathBuf}; +use sysinfo::System; +use tracing::debug; +use uuid::Uuid; + +#[derive(Debug)] +pub struct StagedMod { + pub fs: ModFS, + pub source_path: Utf8PathBuf, + pub is_staging: bool, + pub name: String, +} + +#[derive(Debug)] +pub struct StageMaterial { + pub rules: SPTPathRules, + pub root: Utf8PathBuf, + pub name: String, +} + +pub fn resolve( + inputs: &[Utf8PathBuf], + StageMaterial { root, rules, name }: &StageMaterial, +) -> Result, SError> { + if is_game_root_structure(inputs, rules) { + return stage_loose_files(inputs, rules, root, name).map(|staged| vec![staged]); + } + + inputs + .iter() + .filter_map(|input| { + process_as_directory(input, rules, name) + .or_else(|| process_as_archive(input, rules, root, name)) + }) + .collect() +} + +pub fn any_mod_tool_running(sys: &mut System, mods_to_install: &[StagedMod]) -> Result<(), SError> { + let specific_paths: Vec<_> = mods_to_install + .iter() + .flat_map(|m| m.fs.executables.iter().map(|exe| m.source_path.join(exe))) + .collect(); + + if process::is_running(sys, &specific_paths) { + return Err(SError::ProcessRunning); + } + + Ok(()) +} + +fn process_as_directory( + input: &Utf8PathBuf, + rules: &SPTPathRules, + unknown_mod_name: &str, +) -> Option> { + if !input.is_dir() { + return None; + } + + match folder_matches_game_structure(input, rules) { + Ok(true) => Some( + mod_fs::scan(input, rules) + .map(|fs| staged_from_directory(input, fs, unknown_mod_name)), + ), + Ok(false) => match mod_fs::scan(input, rules) { + Ok(fs) => Some(Ok(staged_from_directory(input, fs, unknown_mod_name))), + Err(SError::UnableToDetermineModId) => None, + Err(error) => Some(Err(error)), + }, + Err(error) => Some(Err(error)), + } +} + +fn process_as_archive( + input: &Utf8PathBuf, + rules: &SPTPathRules, + staging_root: &Utf8Path, + unknown_mod_name: &str, +) -> Option> { + archive::ArchiveFormat::from_path(input) + .map(|_| stage_archive(input, rules, staging_root, unknown_mod_name)) +} + +fn is_game_root_structure(inputs: &[Utf8PathBuf], rules: &SPTPathRules) -> bool { + let roots = [ + get_root_component(&rules.server_mods), + get_root_component(&rules.client_plugins), + ]; + + inputs.iter().any(|path| { + path.file_name() + .map(|name| roots.contains(&Some(name))) + .unwrap_or(false) + }) +} + +fn folder_matches_game_structure(folder: &Utf8Path, rules: &SPTPathRules) -> Result { + let roots = [ + get_root_component(&rules.server_mods), + get_root_component(&rules.client_plugins), + ]; + + for entry in file::read_dir(folder)? { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|name| SError::ParseError(format!("Invalid UTF-8 file name: {name:?}")))?; + + if roots.contains(&Some(name.as_str())) { + return Ok(true); + } + } + + Ok(false) +} + +fn stage_loose_files( + inputs: &[Utf8PathBuf], + rules: &SPTPathRules, + staging_root: &Utf8Path, + unknown_mod_name: &str, +) -> Result { + let uuid = Uuid::new_v4().to_string(); + let dest_dir = staging_root.join(uuid); + file::create_dir_all(&dest_dir)?; + + for input in inputs { + let name = input + .file_name() + .ok_or_else(|| SError::ParseError(format!("Unable to get file name for {input}")))?; + file::copy_recursive(input, &dest_dir.join(name))?; + } + + let fs = mod_fs::scan(&dest_dir, rules)?; + + Ok(StagedMod { + fs, + source_path: dest_dir, + is_staging: true, + name: unknown_mod_name.to_string(), + }) +} + +fn stage_archive( + archive: &Utf8Path, + rules: &SPTPathRules, + staging_root: &Utf8Path, + unknown_mod_name: &str, +) -> Result { + let uuid = Uuid::new_v4().to_string(); + let dest_dir = staging_root.join(uuid); + file::create_dir_all(&dest_dir)?; + + archive::extract(archive, &dest_dir)?; + let fs = mod_fs::scan(&dest_dir, rules)?; + let name = archive.file_stem().unwrap_or(unknown_mod_name).to_string(); + + Ok(StagedMod { + fs, + source_path: dest_dir, + is_staging: true, + name, + }) +} + +fn staged_from_directory(input: &Utf8Path, fs: ModFS, unknown_mod_name: &str) -> StagedMod { + let name = input.file_name().unwrap_or(unknown_mod_name).to_string(); + + StagedMod { + fs, + source_path: input.to_path_buf(), + is_staging: false, + name, + } +} + +fn get_root_component(path: &Utf8Path) -> Option<&str> { + path.components().next().map(|c| c.as_str()) +} + +pub fn clean_up(is_staging: bool, source_path: &Utf8Path) -> Result<(), SError> { + if !is_staging { + return Ok(()); + } + debug!("clean up for {source_path}"); + file::remove_dir_all(source_path) +} From 116d954371a7996ffe7a048fcbe7ff06fc65dd10 Mon Sep 17 00:00:00 2001 From: martesi <96113508+martesi@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:10:30 +0800 Subject: [PATCH 4/7] refactor: simplify process matching --- src-tauri/src/utils/process.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/utils/process.rs b/src-tauri/src/utils/process.rs index a3bd96a..e14668d 100644 --- a/src-tauri/src/utils/process.rs +++ b/src-tauri/src/utils/process.rs @@ -1,19 +1,11 @@ use std::path::Path; use sysinfo::System; -/// Performs the check. Takes a mutable ref to System to allow -/// sysinfo to reuse internal buffers for performance. pub fn is_running>(sys: &mut System, target_paths: &[P]) -> bool { - // Refresh only what we need sys.refresh_processes(); - sys.processes().values().any(|p| { - if let Some(exe_path) = p.exe() { - // Check if the current process path matches any of our targets - return target_paths - .iter() - .any(|target| exe_path == target.as_ref()); - } - false - }) + sys.processes() + .values() + .filter_map(|process| process.exe()) + .any(|exe| target_paths.iter().any(|target| exe == target.as_ref())) } From 2b6b4b92e1d27c9ecede0d6fd792cecfed1f9bb0 Mon Sep 17 00:00:00 2001 From: martesi <96113508+martesi@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:10:45 +0800 Subject: [PATCH 5/7] chore: add test scripts --- package.json | 207 ++++++++++++++++++++++++++------------------------- 1 file changed, 105 insertions(+), 102 deletions(-) diff --git a/package.json b/package.json index 5d10f94..bccb8db 100644 --- a/package.json +++ b/package.json @@ -1,102 +1,105 @@ -{ - "name": "shooter", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "prepare": "lefthook install", - "dev": "vite", - "dev:app": "tauri dev", - "dev:e2e": "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222 tauri dev", - "build": "tsc && vite build", - "preview": "vite preview", - "tauri": "tauri", - "extract": "lingui extract", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" - }, - "dependencies": { - "@lingui/core": "^5.9.0", - "@lingui/react": "^5.9.0", - "@tanstack/react-router": "^1.158.1", - "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-dialog": "^2.6.0", - "@tauri-apps/plugin-log": "~2.8.0", - "@tauri-apps/plugin-opener": "~2.5.3", - "ahooks": "^3.9.6", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "jotai": "^2.17.1", - "lucide-react": "^0.563.0", - "next-themes": "^0.4.6", - "radix-ui": "^1.4.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", - "remeda": "^2.33.4", - "semver": "^7.7.3", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "zod": "^4.3.6" - }, - "devDependencies": { - "@eslint/js": "^9.39.2", - "@storybook/react-vite": "^9.1.10", - "@faker-js/faker": "^10.2.0", - "@lingui/cli": "^5.9.0", - "@lingui/vite-plugin": "^5.9.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.158.1", - "@tauri-apps/cli": "^2.10.0", - "@types/bun": "^1.3.8", - "@types/react": "^19.2.11", - "@types/react-dom": "^19.2.3", - "@types/semver": "^7.7.1", - "@vitejs/plugin-react": "^5.1.3", - "@vitejs/plugin-react-swc": "^4.2.3", - "babel-plugin-react-compiler": "^1.0.0", - "eslint": "^9.39.2", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", - "globals": "^17.3.0", - "jiti": "^2.6.1", - "lefthook": "^2.1.0", - "prettier": "^3.8.1", - "shadcn": "^3.8.3", - "storybook": "^9.1.10", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.54.0", - "unplugin-auto-import": "^21.0.0", - "vite": "^7.3.1", - "vite-tsconfig-paths": "^6.0.5" - }, - "prettier": { - "semi": false, - "singleQuote": true - }, - "lingui": { - "sourceLocale": "en-US", - "locales": [ - "en-US" - ], - "fallbackLocales": { - "default": "en-US" - }, - "catalogs": [ - { - "path": "locales/{locale}", - "include": [ - "src" - ] - } - ], - "compileNamespace": "ts" - }, - "packageManager": "bun@1.3.8", - "trustedDependencies": [ - "@swc/core" - ] -} +{ + "name": "shooter", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "prepare": "lefthook install", + "dev": "vite", + "dev:app": "tauri dev", + "dev:e2e": "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222 tauri dev", + "build": "tsc && vite build", + "test": "bun test", + "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml", + "test:all": "bun run test && bun run test:rust", + "preview": "vite preview", + "tauri": "tauri", + "extract": "lingui extract", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "@lingui/core": "^5.9.0", + "@lingui/react": "^5.9.0", + "@tanstack/react-router": "^1.158.1", + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-log": "~2.8.0", + "@tauri-apps/plugin-opener": "~2.5.3", + "ahooks": "^3.9.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jotai": "^2.17.1", + "lucide-react": "^0.563.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "remeda": "^2.33.4", + "semver": "^7.7.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@storybook/react-vite": "^9.1.10", + "@faker-js/faker": "^10.2.0", + "@lingui/cli": "^5.9.0", + "@lingui/vite-plugin": "^5.9.0", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-plugin": "^1.158.1", + "@tauri-apps/cli": "^2.10.0", + "@types/bun": "^1.3.8", + "@types/react": "^19.2.11", + "@types/react-dom": "^19.2.3", + "@types/semver": "^7.7.1", + "@vitejs/plugin-react": "^5.1.3", + "@vitejs/plugin-react-swc": "^4.2.3", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.39.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^17.3.0", + "jiti": "^2.6.1", + "lefthook": "^2.1.0", + "prettier": "^3.8.1", + "shadcn": "^3.8.3", + "storybook": "^9.1.10", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.54.0", + "unplugin-auto-import": "^21.0.0", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.0.5" + }, + "prettier": { + "semi": false, + "singleQuote": true + }, + "lingui": { + "sourceLocale": "en-US", + "locales": [ + "en-US" + ], + "fallbackLocales": { + "default": "en-US" + }, + "catalogs": [ + { + "path": "locales/{locale}", + "include": [ + "src" + ] + } + ], + "compileNamespace": "ts" + }, + "packageManager": "bun@1.3.8", + "trustedDependencies": [ + "@swc/core" + ] +} From 4b0c2b62f83c588e0dfb28971885f15515c91a08 Mon Sep 17 00:00:00 2001 From: martesi <96113508+martesi@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:10:56 +0800 Subject: [PATCH 6/7] ci: run frontend and Rust tests --- .github/workflows/test.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9850cd0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.8 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install frontend dependencies + run: bun ci --ignore-scripts + + - name: Frontend tests + run: bun run test + + - name: Rust tests + run: bun run test:rust From c67af3daaec92de79251a06611f2295f64966beb Mon Sep 17 00:00:00 2001 From: martesi <96113508+martesi@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:19:16 +0800 Subject: [PATCH 7/7] test: canonicalize temporary library paths --- src-tauri/tests/common/mod.rs | 36 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src-tauri/tests/common/mod.rs b/src-tauri/tests/common/mod.rs index 1969ca9..1c90cd6 100644 --- a/src-tauri/tests/common/mod.rs +++ b/src-tauri/tests/common/mod.rs @@ -1,44 +1,38 @@ -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use mod_keeper_lib::models::paths::SPTPathRules; use std::fs; use tempfile::TempDir; -/// Helper to setup a dummy SPT environment so Library::create doesn't fail -pub fn setup_test_env() -> (TempDir, camino::Utf8PathBuf, camino::Utf8PathBuf) { +pub fn setup_test_env() -> (TempDir, Utf8PathBuf, Utf8PathBuf) { let tmp = tempfile::tempdir().unwrap(); - let root = camino::Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); let game_root = root.join("game"); let repo_root = root.join("repo"); + fs::create_dir_all(&game_root).unwrap(); + fs::create_dir_all(&repo_root).unwrap(); - std::fs::create_dir_all(&game_root).unwrap(); - std::fs::create_dir_all(&repo_root).unwrap(); - - // 1. Get the rules to find where SPT expects files + let game_root = canonicalize(&game_root); + let repo_root = canonicalize(&repo_root); let rules = SPTPathRules::new(&game_root); - // 2. Create DUMMY files so canonicalize() doesn't fail with "os error 2" - let essential_files = [&rules.server_exe, &rules.client_exe]; - - for path in essential_files { + for path in [&rules.server_exe, &rules.client_exe] { if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); + fs::create_dir_all(parent).unwrap(); } - std::fs::write(path, "dummy").unwrap(); + fs::write(path, "dummy").unwrap(); } - // 3. Create registry.json file with SPT_Version if let Some(parent) = rules.server_registry.parent() { - std::fs::create_dir_all(parent).unwrap(); + fs::create_dir_all(parent).unwrap(); } let registry_json = r#"{"SPT_Version": "SPT 4.0.11 - 278e72"}"#; - std::fs::write(&rules.server_registry, registry_json).unwrap(); + fs::write(&rules.server_registry, registry_json).unwrap(); (tmp, game_root, repo_root) } -/// Mock a mod folder structure -#[allow(dead_code)] // not every test binary uses this helper +#[allow(dead_code)] pub fn create_test_mod(path: &Utf8Path, name: &str, is_server: bool) { let rules = SPTPathRules::default(); let mod_dir = if is_server { @@ -50,3 +44,7 @@ pub fn create_test_mod(path: &Utf8Path, name: &str, is_server: bool) { fs::create_dir_all(&mod_dir).unwrap(); fs::write(mod_dir.join("content.txt"), name).unwrap(); } + +fn canonicalize(path: &Utf8Path) -> Utf8PathBuf { + Utf8PathBuf::from_path_buf(dunce::canonicalize(path).unwrap()).unwrap() +}